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> providersListing() { + List> out = new ArrayList<>(); + for (OAuthProvider provider : providers.values()) { + Map 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 row = stateStore.consume(stateToken); + if (row.isEmpty()) { + throw new StateValidationError("state is invalid, expired, or already used"); + } + OAuthState state = row.get(); + if (!state.slot().equals(slot)) { + throw new StateValidationError("state slot does not match callback slot"); + } + + 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.pruneExpired(); + return new CallbackResult(session, state.nextUrl()); + } + + private OAuthProvider requireProvider(String slot) { + OAuthProvider provider = providers.get(slot); + if (provider == null) { + throw new ProviderNotConfigured("OAuth provider '" + slot + "' is not configured"); + } + return provider; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthHandlers.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthHandlers.java new file mode 100644 index 00000000..08b40c27 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthHandlers.java @@ -0,0 +1,118 @@ +package org.byteveda.taskito.dashboard.auth.oauth; + +import com.sun.net.httpserver.HttpExchange; +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.byteveda.taskito.dashboard.auth.AuthStore; +import org.byteveda.taskito.dashboard.auth.Cookies; +import org.byteveda.taskito.dashboard.auth.oauth.error.AllowlistDenied; +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.support.Http; + +/** + * The three public OAuth HTTP routes. Unlike the JSON API routes these emit 302 + * redirects (and set the session cookies on success), so they live outside the + * JSON router and are dispatched directly. + * + *

Handled gracefully when OAuth is not configured (a {@code null} flow): + * {@code /api/auth/providers} reports password-only, and the start/callback + * routes report 404 {@code oauth_not_configured}. + */ +public final class OAuthHandlers { + static final String PROVIDERS_PATH = "/api/auth/providers"; + static final String START_PREFIX = "/api/auth/oauth/start/"; + static final String CALLBACK_PREFIX = "/api/auth/oauth/callback/"; + + private final OAuthFlow flow; + private final boolean secureCookies; + + public OAuthHandlers(OAuthFlow flow, boolean secureCookies) { + this.flow = flow; + this.secureCookies = secureCookies; + } + + /** + * Serve an OAuth route. Returns {@code false} (so the caller falls through to + * the normal API pipeline) when the path/method is not one of ours. + */ + public boolean serve(HttpExchange exchange, String path, String method, Map query) + throws IOException { + if (!"GET".equals(method)) { + return false; + } + if (path.equals(PROVIDERS_PATH)) { + providers(exchange); + return true; + } + if (path.startsWith(START_PREFIX)) { + start(exchange, slotFrom(path, START_PREFIX), query); + return true; + } + if (path.startsWith(CALLBACK_PREFIX)) { + callback(exchange, slotFrom(path, CALLBACK_PREFIX), query); + return true; + } + return false; + } + + private void providers(HttpExchange exchange) throws IOException { + boolean passwordEnabled = flow == null || flow.passwordAuthEnabled(); + List> listing = flow == null ? List.of() : flow.providersListing(); + Http.respondJson(exchange, 200, Map.of("password_enabled", passwordEnabled, "providers", listing)); + } + + private void start(HttpExchange exchange, String slot, Map query) throws IOException { + if (flow == null) { + Http.respondError(exchange, 404, "oauth_not_configured"); + return; + } + try { + redirect(exchange, flow.start(slot, query.get("next")), List.of()); + } catch (ProviderNotConfigured e) { + Http.respondError(exchange, 404, "oauth_not_configured"); + } + } + + private void callback(HttpExchange exchange, String slot, Map query) throws IOException { + if (flow == null) { + Http.respondError(exchange, 404, "oauth_not_configured"); + return; + } + try { + OAuthFlow.CallbackResult result = + flow.handleCallback(slot, query.get("code"), query.get("state"), query.get("error")); + long ttl = AuthStore.DEFAULT_SESSION_TTL_SECONDS; + List cookies = List.of( + Cookies.sessionCookie(result.session().token(), secureCookies, ttl), + Cookies.csrfCookie(result.session().csrfToken(), secureCookies, ttl)); + redirect(exchange, result.nextUrl(), cookies); + } catch (ProviderNotConfigured e) { + Http.respondError(exchange, 404, "oauth_not_configured"); + } catch (StateValidationError e) { + redirect(exchange, "/login?error=oauth_state_invalid", List.of()); + } catch (AllowlistDenied e) { + redirect(exchange, "/login?error=oauth_denied", List.of()); + } catch (IdentityFetchError e) { + redirect(exchange, "/login?error=oauth_failed", List.of()); + } + } + + private static void redirect(HttpExchange exchange, String location, List setCookies) throws IOException { + var headers = exchange.getResponseHeaders(); + headers.set("Location", location); + headers.set("Cache-Control", "no-store"); + for (String cookie : setCookies) { + headers.add("Set-Cookie", cookie); + } + exchange.sendResponseHeaders(302, -1); + } + + private static String slotFrom(String path, String prefix) { + return URLDecoder.decode(path.substring(prefix.length()), StandardCharsets.UTF_8); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthStateStore.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthStateStore.java new file mode 100644 index 00000000..bf4ea7fd --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthStateStore.java @@ -0,0 +1,132 @@ +package org.byteveda.taskito.dashboard.auth.oauth; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import org.byteveda.taskito.dashboard.auth.Tokens; +import org.byteveda.taskito.dashboard.auth.oauth.model.OAuthState; +import org.byteveda.taskito.dashboard.store.SettingsAccess; +import org.byteveda.taskito.dashboard.support.Json; + +/** + * Create, consume (read+delete), and prune short-lived OAuth state rows. + * + *

Rows live in the settings KV under the {@code auth:oauth_state:} + * namespace alongside sessions, so they work uniformly across SQLite / Postgres + * / Redis with no new migrations. Each row carries the {@code nonce}, PKCE + * {@code code_verifier}, target {@code slot}, and post-login {@code next_url}; + * the {@code state} token itself is the key suffix and is not stored. + * + *

Single-use invariant: {@link #consume} always deletes the row before + * parsing, so a replayed {@code state} finds nothing — even if the row is + * malformed. + */ +public final class OAuthStateStore { + public static final String STATE_PREFIX = "auth:oauth_state:"; + /** 5 min — covers consent UX plus reasonable network latency. */ + public static final long DEFAULT_STATE_TTL_SECONDS = 5 * 60; + + private final SettingsAccess settings; + + public OAuthStateStore(SettingsAccess settings) { + this.settings = settings; + } + + /** Mint a fresh state/nonce/verifier triple, persist it, and return it. */ + public OAuthState create(String slot, String nextUrl) { + long now = nowSeconds(); + OAuthState state = new OAuthState( + Tokens.urlSafe(Tokens.STATE_TOKEN_BYTES), + Tokens.urlSafe(Tokens.NONCE_BYTES), + Tokens.urlSafe(Tokens.CODE_VERIFIER_BYTES), + slot, + nextUrl, + now, + now + DEFAULT_STATE_TTL_SECONDS); + Map row = new LinkedHashMap<>(); + row.put("nonce", state.nonce()); + row.put("code_verifier", state.codeVerifier()); + row.put("slot", state.slot()); + row.put("next_url", state.nextUrl()); + row.put("created_at", state.createdAt()); + row.put("expires_at", state.expiresAt()); + settings.setSetting(STATE_PREFIX + state.state(), Json.toString(row)); + return state; + } + + /** + * Look up {@code stateToken} and delete it atomically. Empty if missing, + * malformed, or expired. The delete happens before parsing so a replay + * of the same state never re-validates. + */ + public Optional consume(String stateToken) { + if (stateToken == null || stateToken.isEmpty()) { + return Optional.empty(); + } + String key = STATE_PREFIX + stateToken; + Optional raw = settings.getSetting(key); + if (raw.isEmpty()) { + return Optional.empty(); + } + // Delete first: any subsequent request with the same state sees nothing. + settings.deleteSetting(key); + Map data = Json.parseMap(raw.get()); + if (data == null) { + return Optional.empty(); + } + OAuthState state; + try { + state = new OAuthState( + stateToken, + (String) data.get("nonce"), + (String) data.get("code_verifier"), + (String) data.get("slot"), + (String) data.get("next_url"), + asLong(data.get("created_at")), + asLong(data.get("expires_at"))); + } catch (RuntimeException e) { + return Optional.empty(); + } + if (state.isExpired(nowSeconds())) { + return Optional.empty(); + } + return Optional.of(state); + } + + /** Best-effort sweep of expired state rows. Returns the count removed. */ + public int pruneExpired() { + long now = nowSeconds(); + int removed = 0; + for (Map.Entry entry : settings.listSettings().entrySet()) { + if (!entry.getKey().startsWith(STATE_PREFIX)) { + continue; + } + Map data = Json.parseMap(entry.getValue()); + if (data == null) { + continue; + } + long expires; + try { + expires = asLong(data.get("expires_at")); + } catch (RuntimeException e) { + continue; + } + if (expires <= now) { + settings.deleteSetting(entry.getKey()); + removed++; + } + } + return removed; + } + + private static long asLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + throw new IllegalArgumentException("expected numeric value, got " + value); + } + + private static long nowSeconds() { + return System.currentTimeMillis() / 1000; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/UrlSafety.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/UrlSafety.java new file mode 100644 index 00000000..df4f5103 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/UrlSafety.java @@ -0,0 +1,38 @@ +package org.byteveda.taskito.dashboard.auth.oauth; + +import java.net.URI; + +/** + * Open-redirect guard for the post-login {@code next} target. + * + *

Only same-origin relative paths rooted at {@code /} are accepted. Absolute + * URLs ({@code https://evil.com}), protocol-relative URLs ({@code //evil.com}), + * and backslash tricks ({@code /\evil}) are rejected so an attacker can't craft + * a login link that bounces the browser off-site after authentication. + */ +public final class UrlSafety { + + private UrlSafety() {} + + /** Whether {@code path} is safe to use as a same-origin post-login redirect. */ + public static boolean isSafeRedirect(String path) { + if (path == null || path.isEmpty()) { + return false; + } + if (!path.startsWith("/")) { + return false; + } + // "//host" is protocol-relative; "/\host" is a browser-normalised variant. + if (path.startsWith("//") || path.startsWith("/\\")) { + return false; + } + URI uri; + try { + uri = URI.create(path); + } catch (IllegalArgumentException e) { + return false; + } + // A bare rooted path has neither a scheme nor an authority/host. + return uri.getScheme() == null && uri.getAuthority() == null && uri.getHost() == null; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/config/OAuthConfig.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/config/OAuthConfig.java new file mode 100644 index 00000000..a3fc02ac --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/config/OAuthConfig.java @@ -0,0 +1,361 @@ +package org.byteveda.taskito.dashboard.auth.oauth.config; + +import java.net.URI; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import org.byteveda.taskito.dashboard.auth.oauth.error.OAuthConfigError; + +/** + * Operator-facing OAuth configuration, parsed entirely from environment + * variables (secrets are never persisted to the settings DB). + * + *

{@code redirectBaseUrl} is the public origin the dashboard is served at; + * every callback URL is derived from it. OAuth is considered disabled when no + * provider is configured. {@link #fromEnv(Map)} fails fast on partial provider + * configuration rather than silently ignoring it. + */ +public record OAuthConfig( + String redirectBaseUrl, + GoogleConfig google, + GitHubConfig github, + List oidc, + boolean passwordAuthEnabled, + List adminEmails) { + + // ---- env var names (cross-SDK contract — do not rename) ---------------- + + public static final String ENV_REDIRECT_BASE_URL = "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL"; + public static final String ENV_PASSWORD_AUTH_ENABLED = "TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED"; + public static final String ENV_ADMIN_EMAILS = "TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS"; + public static final String ENV_GOOGLE_CLIENT_ID = "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID"; + public static final String ENV_GOOGLE_CLIENT_SECRET = "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET"; + public static final String ENV_GOOGLE_ALLOWED_DOMAINS = "TASKITO_DASHBOARD_OAUTH_GOOGLE_ALLOWED_DOMAINS"; + public static final String ENV_GITHUB_CLIENT_ID = "TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID"; + public static final String ENV_GITHUB_CLIENT_SECRET = "TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET"; + public static final String ENV_GITHUB_ALLOWED_ORGS = "TASKITO_DASHBOARD_OAUTH_GITHUB_ALLOWED_ORGS"; + public static final String ENV_OIDC_PROVIDERS = "TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS"; + public static final String ENV_OIDC_PREFIX = "TASKITO_DASHBOARD_OAUTH_OIDC_"; + + static final Pattern SLOT_PATTERN = Pattern.compile("^[a-z][a-z0-9_-]{0,31}$"); + static final Set RESERVED_SLOTS = Set.of("google", "github"); + /** Hosts where {@code http://} is accepted for a redirect URL (dev only). */ + static final Set LOCAL_HOSTS = Set.of("localhost", "127.0.0.1", "::1"); + + public OAuthConfig { + validateRedirectBaseUrl(redirectBaseUrl); + oidc = List.copyOf(oidc); + adminEmails = List.copyOf(adminEmails); + } + + // ---- introspection ----------------------------------------------------- + + public boolean isEnabled() { + return google != null || github != null || !oidc.isEmpty(); + } + + /** Configured providers in display order (Google, GitHub, then OIDC slots). */ + public List providers() { + List out = new ArrayList<>(); + if (google != null) { + out.add(google); + } + if (github != null) { + out.add(github); + } + out.addAll(oidc); + return out; + } + + public Optional findProvider(String slot) { + return providers().stream().filter(p -> p.slot().equals(slot)).findFirst(); + } + + /** Compact provider summary for the login UI (no secrets), in display order. */ + public List> providersListing() { + List> out = new ArrayList<>(); + for (ProviderConfig p : providers()) { + Map entry = new LinkedHashMap<>(); + entry.put("slot", p.slot()); + entry.put("label", p.label()); + entry.put("type", p.type()); + out.add(entry); + } + return out; + } + + public String callbackUrl(String slot) { + return stripTrailingSlashes(redirectBaseUrl) + "/api/auth/oauth/callback/" + slot; + } + + // ---- env parsing ------------------------------------------------------- + + /** Parse from {@link System#getenv()}. */ + public static Optional fromEnv() { + return fromEnv(System.getenv()); + } + + /** + * Parse from {@code env}. Empty when neither a base URL nor any provider is + * configured (OAuth off). Throws {@link OAuthConfigError} on partial provider + * configuration or an unusable redirect URL. + */ + public static Optional fromEnv(Map env) { + String baseUrl = trimmed(env, ENV_REDIRECT_BASE_URL); + String googleId = trimmed(env, ENV_GOOGLE_CLIENT_ID); + String githubId = trimmed(env, ENV_GITHUB_CLIENT_ID); + String oidcRaw = trimmed(env, ENV_OIDC_PROVIDERS); + + boolean anyProviderSignal = !googleId.isEmpty() || !githubId.isEmpty() || !oidcRaw.isEmpty(); + if (!anyProviderSignal && baseUrl.isEmpty()) { + return Optional.empty(); + } + if (anyProviderSignal && baseUrl.isEmpty()) { + throw new OAuthConfigError(ENV_REDIRECT_BASE_URL + " must be set when any OAuth provider is configured"); + } + + GoogleConfig google = googleId.isEmpty() ? null : parseGoogle(env); + GitHubConfig github = githubId.isEmpty() ? null : parseGithub(env); + List oidc = parseOidcSlots(env, oidcRaw); + boolean passwordEnabled = parseBool(env.getOrDefault(ENV_PASSWORD_AUTH_ENABLED, "true"), true); + List adminEmails = splitCsv(env.get(ENV_ADMIN_EMAILS)); + + OAuthConfig config = new OAuthConfig(baseUrl, google, github, oidc, passwordEnabled, adminEmails); + if (!config.isEnabled() && !passwordEnabled) { + throw new OAuthConfigError("password auth disabled but no OAuth providers configured — no way to log in"); + } + return Optional.of(config); + } + + private static GoogleConfig parseGoogle(Map env) { + String clientId = trimmed(env, ENV_GOOGLE_CLIENT_ID); + String secret = trimmed(env, ENV_GOOGLE_CLIENT_SECRET); + if (secret.isEmpty()) { + throw new OAuthConfigError(ENV_GOOGLE_CLIENT_SECRET + " is required when the Google client id is set"); + } + return new GoogleConfig(clientId, secret, splitCsv(env.get(ENV_GOOGLE_ALLOWED_DOMAINS))); + } + + private static GitHubConfig parseGithub(Map env) { + String clientId = trimmed(env, ENV_GITHUB_CLIENT_ID); + String secret = trimmed(env, ENV_GITHUB_CLIENT_SECRET); + if (secret.isEmpty()) { + throw new OAuthConfigError(ENV_GITHUB_CLIENT_SECRET + " is required when the GitHub client id is set"); + } + return new GitHubConfig(clientId, secret, splitCsv(env.get(ENV_GITHUB_ALLOWED_ORGS))); + } + + private static List parseOidcSlots(Map env, String slotsRaw) { + List slotNames = splitCsv(slotsRaw); + if (slotNames.isEmpty()) { + return List.of(); + } + List out = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (String rawSlot : slotNames) { + String slot = rawSlot.toLowerCase(Locale.ROOT); + if (!seen.add(slot)) { + throw new OAuthConfigError("OIDC slot '" + slot + "' listed twice in " + ENV_OIDC_PROVIDERS); + } + out.add(parseOidcSlot(env, slot)); + } + return out; + } + + private static OidcConfig parseOidcSlot(Map env, String slot) { + String prefix = ENV_OIDC_PREFIX + slot.toUpperCase(Locale.ROOT).replace('-', '_'); + String clientId = trimmed(env, prefix + "_CLIENT_ID"); + String secret = trimmed(env, prefix + "_CLIENT_SECRET"); + String discovery = trimmed(env, prefix + "_DISCOVERY_URL"); + if (clientId.isEmpty() || secret.isEmpty() || discovery.isEmpty()) { + throw new OAuthConfigError( + "OIDC slot '" + slot + "' requires " + prefix + "_CLIENT_ID, _CLIENT_SECRET, and _DISCOVERY_URL"); + } + String label = trimmed(env, prefix + "_LABEL"); + if (label.isEmpty()) { + label = titleCase(slot.replace('-', ' ').replace('_', ' ')); + } + List allowed = splitCsv(env.get(prefix + "_ALLOWED_DOMAINS")); + // The OidcConfig constructor validates the slot pattern / reserved names. + return new OidcConfig(slot, clientId, secret, discovery, allowed, label); + } + + // ---- validation helpers ------------------------------------------------ + + private static void validateRedirectBaseUrl(String url) { + if (url == null || url.isEmpty()) { + throw new OAuthConfigError("redirect base URL must be set when OAuth is enabled"); + } + URI parsed; + try { + parsed = URI.create(url); + } catch (IllegalArgumentException e) { + throw new OAuthConfigError("redirect base URL is not a valid URL: " + url); + } + String scheme = parsed.getScheme() == null ? "" : parsed.getScheme().toLowerCase(Locale.ROOT); + if (!scheme.equals("http") && !scheme.equals("https")) { + throw new OAuthConfigError("redirect base URL must be http(s), got '" + scheme + "'"); + } + String host = hostOf(parsed); + if (host.isEmpty()) { + throw new OAuthConfigError("redirect base URL must include a hostname"); + } + if (scheme.equals("http") && !LOCAL_HOSTS.contains(host)) { + throw new OAuthConfigError("redirect base URL must use https for non-local host " + host); + } + } + + /** Host with any IPv6 brackets stripped, lowercased; empty when absent. */ + private static String hostOf(URI uri) { + String host = uri.getHost(); + if (host == null) { + return ""; + } + if (host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); + } + return host.toLowerCase(Locale.ROOT); + } + + private static String stripTrailingSlashes(String url) { + int end = url.length(); + while (end > 0 && url.charAt(end - 1) == '/') { + end--; + } + return url.substring(0, end); + } + + private static List splitCsv(String raw) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + List out = new ArrayList<>(); + for (String part : raw.split(",")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + out.add(trimmed); + } + } + return out; + } + + private static boolean parseBool(String raw, boolean fallback) { + String lowered = raw.strip().toLowerCase(Locale.ROOT); + return switch (lowered) { + case "1", "true", "yes", "on" -> true; + case "0", "false", "no", "off" -> false; + default -> fallback; + }; + } + + private static String titleCase(String value) { + StringBuilder out = new StringBuilder(value.length()); + boolean startOfWord = true; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (Character.isWhitespace(c)) { + startOfWord = true; + out.append(c); + } else if (startOfWord) { + out.append(Character.toUpperCase(c)); + startOfWord = false; + } else { + out.append(Character.toLowerCase(c)); + } + } + return out.toString(); + } + + private static String trimmed(Map env, String key) { + String value = env.get(key); + return value == null ? "" : value.trim(); + } + + // ---- provider sub-configs --------------------------------------------- + + /** Common shape shared by the three provider configs (no secrets exposed). */ + public sealed interface ProviderConfig permits GoogleConfig, GitHubConfig, OidcConfig { + String slot(); + + String label(); + + String type(); + } + + public record GoogleConfig(String clientId, String clientSecret, List allowedDomains) + implements ProviderConfig { + public GoogleConfig { + allowedDomains = List.copyOf(allowedDomains); + } + + @Override + public String slot() { + return "google"; + } + + @Override + public String label() { + return "Google"; + } + + @Override + public String type() { + return "google"; + } + } + + public record GitHubConfig(String clientId, String clientSecret, List allowedOrgs) + implements ProviderConfig { + public GitHubConfig { + allowedOrgs = List.copyOf(allowedOrgs); + } + + @Override + public String slot() { + return "github"; + } + + @Override + public String label() { + return "GitHub"; + } + + @Override + public String type() { + return "github"; + } + } + + public record OidcConfig( + String slot, + String clientId, + String clientSecret, + String discoveryUrl, + List allowedDomains, + String label) + implements ProviderConfig { + public OidcConfig { + if (slot == null || !SLOT_PATTERN.matcher(slot).matches()) { + throw new OAuthConfigError("OIDC slot '" + slot + "' must match " + SLOT_PATTERN.pattern()); + } + if (RESERVED_SLOTS.contains(slot)) { + throw new OAuthConfigError("OIDC slot '" + slot + "' collides with a built-in provider"); + } + if (discoveryUrl == null || discoveryUrl.isBlank()) { + throw new OAuthConfigError("OIDC slot '" + slot + "': discovery URL is required"); + } + allowedDomains = List.copyOf(allowedDomains); + } + + @Override + public String type() { + return "oidc"; + } + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/config/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/config/package-info.java new file mode 100644 index 00000000..c70dc58a --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/config/package-info.java @@ -0,0 +1,2 @@ +/** Operator-facing OAuth configuration and env-var parsing (no secrets persisted). */ +package org.byteveda.taskito.dashboard.auth.oauth.config; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/AllowlistDenied.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/AllowlistDenied.java new file mode 100644 index 00000000..511ba4a3 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/AllowlistDenied.java @@ -0,0 +1,10 @@ +package org.byteveda.taskito.dashboard.auth.oauth.error; + +/** A verified identity was rejected by a configured domain/org allowlist. */ +public final class AllowlistDenied extends OAuthException { + private static final long serialVersionUID = 1L; + + public AllowlistDenied(String message) { + super(message); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/IdentityFetchError.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/IdentityFetchError.java new file mode 100644 index 00000000..e66fd29b --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/IdentityFetchError.java @@ -0,0 +1,14 @@ +package org.byteveda.taskito.dashboard.auth.oauth.error; + +/** A token exchange, userinfo fetch, or id-token claim check failed. */ +public final class IdentityFetchError extends OAuthException { + private static final long serialVersionUID = 1L; + + public IdentityFetchError(String message) { + super(message); + } + + public IdentityFetchError(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/OAuthConfigError.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/OAuthConfigError.java new file mode 100644 index 00000000..c0a8347a --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/OAuthConfigError.java @@ -0,0 +1,10 @@ +package org.byteveda.taskito.dashboard.auth.oauth.error; + +/** Env-var configuration is invalid or incomplete (fail-fast on partial setup). */ +public final class OAuthConfigError extends OAuthException { + private static final long serialVersionUID = 1L; + + public OAuthConfigError(String message) { + super(message); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/OAuthException.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/OAuthException.java new file mode 100644 index 00000000..7bdbc4a9 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/OAuthException.java @@ -0,0 +1,14 @@ +package org.byteveda.taskito.dashboard.auth.oauth.error; + +/** Base class for any OAuth-flow error surfaced to the handler layer. */ +public class OAuthException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public OAuthException(String message) { + super(message); + } + + public OAuthException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/ProviderNotConfigured.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/ProviderNotConfigured.java new file mode 100644 index 00000000..3d7684d4 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/ProviderNotConfigured.java @@ -0,0 +1,10 @@ +package org.byteveda.taskito.dashboard.auth.oauth.error; + +/** A request referenced an OAuth slot that is not registered. */ +public final class ProviderNotConfigured extends OAuthException { + private static final long serialVersionUID = 1L; + + public ProviderNotConfigured(String message) { + super(message); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/StateValidationError.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/StateValidationError.java new file mode 100644 index 00000000..f7171e9d --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/StateValidationError.java @@ -0,0 +1,10 @@ +package org.byteveda.taskito.dashboard.auth.oauth.error; + +/** The callback state is missing, expired, replayed, or does not match the slot. */ +public final class StateValidationError extends OAuthException { + private static final long serialVersionUID = 1L; + + public StateValidationError(String message) { + super(message); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/package-info.java new file mode 100644 index 00000000..5fb1903c --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/package-info.java @@ -0,0 +1,2 @@ +/** The OAuth-flow exception hierarchy surfaced to the handler layer. */ +package org.byteveda.taskito.dashboard.auth.oauth.error; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/model/OAuthState.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/model/OAuthState.java new file mode 100644 index 00000000..b9325745 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/model/OAuthState.java @@ -0,0 +1,15 @@ +package org.byteveda.taskito.dashboard.auth.oauth.model; + +/** + * One in-flight OAuth flow, stashed server-side between the authorize redirect + * and the callback. {@code createdAt}/{@code expiresAt} are Unix seconds + * (matching sessions). The {@code state} token is the KV key suffix and is never + * serialised into the stored record. + */ +public record OAuthState( + String state, String nonce, String codeVerifier, String slot, String nextUrl, long createdAt, long expiresAt) { + + public boolean isExpired(long nowSeconds) { + return nowSeconds >= expiresAt; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/model/ProviderIdentity.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/model/ProviderIdentity.java new file mode 100644 index 00000000..eb88c605 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/model/ProviderIdentity.java @@ -0,0 +1,13 @@ +package org.byteveda.taskito.dashboard.auth.oauth.model; + +/** + * The normalised "who just logged in" every provider returns after a successful + * flow. + * + *

{@code subject} is the provider's stable unique id (OIDC {@code sub}, + * GitHub numeric id) — never the email, which can change. Together with + * {@code slot} it forms the Taskito username {@code :}. + * {@code email}/{@code name}/{@code picture} may be {@code null}. + */ +public record ProviderIdentity( + String slot, String subject, String email, boolean emailVerified, String name, String picture) {} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/model/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/model/package-info.java new file mode 100644 index 00000000..1144b351 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/model/package-info.java @@ -0,0 +1,2 @@ +/** Immutable OAuth data records: in-flight state and normalised provider identity. */ +package org.byteveda.taskito.dashboard.auth.oauth.model; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/package-info.java new file mode 100644 index 00000000..5b3a8733 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/package-info.java @@ -0,0 +1,26 @@ +/** + * OAuth2 / OIDC login for the dashboard, alongside password auth. + * + *

Adds Google, GitHub, and one-or-more generic OIDC providers (Okta, Auth0, + * Keycloak, Microsoft Entra, …). Auth state lives in the same settings KV store + * as sessions and users, so no schema changes are needed. OAuth users are keyed + * {@code :} and carry the {@code oauth:} sentinel that the + * password verifier rejects. + * + *

The layer is split into acyclic subpackages (lower ones never import + * higher): + *

+ */ +package org.byteveda.taskito.dashboard.auth.oauth; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/AbstractOidcProvider.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/AbstractOidcProvider.java new file mode 100644 index 00000000..3e591932 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/AbstractOidcProvider.java @@ -0,0 +1,204 @@ +package org.byteveda.taskito.dashboard.auth.oauth.provider; + +import java.net.URI; +import java.net.http.HttpClient; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.byteveda.taskito.dashboard.auth.oauth.error.AllowlistDenied; +import org.byteveda.taskito.dashboard.auth.oauth.error.IdentityFetchError; +import org.byteveda.taskito.dashboard.auth.oauth.model.OAuthState; +import org.byteveda.taskito.dashboard.auth.oauth.model.ProviderIdentity; +import org.byteveda.taskito.dashboard.support.Json; + +/** + * Shared OIDC machinery for Google and generic OIDC providers: discovery-doc + * caching, the authorize-URL builder, the token exchange, and id-token + * validation. + * + *

Holds an {@link IdTokenValidator}; constructing a subclass therefore forces + * the nimbus dependency to load, so its absence is caught at flow-build time. + * All endpoints read from the discovery document are forced to https (http only + * for localhost) — an SSRF guard against a poisoned discovery doc redirecting + * the token/JWKS fetch at an internal address. + */ +abstract class AbstractOidcProvider implements OAuthProvider { + private static final String SCOPE = "openid email profile"; + /** Hosts where a discovered endpoint may be reached over plain http (dev only). */ + private static final Set LOCAL_HOSTS = Set.of("localhost", "127.0.0.1", "::1"); + + private final HttpClient http; + private final IdTokenValidator validator; + private volatile Map discovery; + + AbstractOidcProvider(HttpClient http) { + this.http = http; + this.validator = new IdTokenValidator(); + } + + protected abstract String clientId(); + + protected abstract String clientSecret(); + + protected abstract String discoveryUrl(); + + /** Provider-specific authorize hints (e.g. Google's {@code prompt}/{@code hd}). */ + protected Map extraAuthParams() { + return Map.of(); + } + + @Override + public final String authorizationUrl(OAuthState state, String redirectUri) { + Map params = new LinkedHashMap<>(); + params.put("response_type", "code"); + params.put("client_id", clientId()); + params.put("redirect_uri", redirectUri); + params.put("scope", SCOPE); + params.put("state", state.state()); + params.put("nonce", state.nonce()); + params.put("code_challenge", Pkce.challenge(state.codeVerifier())); + params.put("code_challenge_method", Pkce.METHOD); + params.putAll(extraAuthParams()); + return endpoint("authorization_endpoint") + "?" + OAuthHttp.formEncode(params); + } + + @Override + public final ProviderIdentity exchangeCode( + String code, String codeVerifier, String redirectUri, String expectedNonce) { + Map token = fetchToken(code, codeVerifier, redirectUri); + Object idToken = token.get("id_token"); + if (!(idToken instanceof String rawIdToken) || rawIdToken.isEmpty()) { + throw new IdentityFetchError("no id_token in token response"); + } + Map claims = validator.validate( + rawIdToken, + endpoint("jwks_uri"), + stringField(discovery(), "issuer"), + clientId(), + expectedNonce, + System.currentTimeMillis() / 1000); + return new ProviderIdentity( + slot(), + String.valueOf(claims.get("sub")), + stringClaim(claims, "email"), + Boolean.TRUE.equals(claims.get("email_verified")), + stringClaim(claims, "name"), + stringClaim(claims, "picture")); + } + + private Map fetchToken(String code, String codeVerifier, String redirectUri) { + Map form = new LinkedHashMap<>(); + form.put("grant_type", "authorization_code"); + form.put("code", code); + form.put("redirect_uri", redirectUri); + form.put("code_verifier", codeVerifier); + form.put("client_id", clientId()); + form.put("client_secret", clientSecret()); + OAuthHttp.HttpResult result = OAuthHttp.postForm(http, endpoint("token_endpoint"), form, Map.of()); + if (result.status() >= 400) { + throw new IdentityFetchError("token exchange failed with status " + result.status()); + } + Map token = Json.parseMap(result.body()); + if (token == null) { + throw new IdentityFetchError("token endpoint returned a non-JSON body"); + } + return token; + } + + private Map discovery() { + Map local = discovery; + if (local != null) { + return local; + } + synchronized (this) { + if (discovery == null) { + OAuthHttp.HttpResult result = OAuthHttp.get(http, discoveryUrl(), Map.of()); + if (result.status() >= 400) { + throw new IdentityFetchError("discovery document fetch failed with status " + result.status()); + } + Map parsed = Json.parseMap(result.body()); + if (parsed == null) { + throw new IdentityFetchError("discovery document is not a JSON object"); + } + discovery = parsed; + } + return discovery; + } + } + + /** Read a discovered endpoint URL and force https on it (localhost may use http). */ + private String endpoint(String key) { + return enforceHttps(stringField(discovery(), key)); + } + + /** + * Shared domain allowlist check (Google + generic OIDC). A non-empty + * allowlist requires a verified email whose domain is listed. + */ + protected static void checkDomainAllowlist(List allowedDomains, ProviderIdentity identity) { + if (allowedDomains.isEmpty()) { + return; + } + if (identity.email() == null || !identity.emailVerified()) { + throw new AllowlistDenied("a verified email is required for the domain check"); + } + Set allowed = + allowedDomains.stream().map(d -> d.toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); + String domain = emailDomain(identity.email()); + if (domain == null || !allowed.contains(domain)) { + throw new AllowlistDenied("email domain is not in the allowed domains list"); + } + } + + private static String emailDomain(String email) { + int at = email.lastIndexOf('@'); + if (at < 0 || at == email.length() - 1) { + return null; + } + return email.substring(at + 1).toLowerCase(Locale.ROOT); + } + + private static String enforceHttps(String url) { + if (url == null || url.isEmpty()) { + throw new IdentityFetchError("discovery document is missing a required endpoint"); + } + URI uri; + try { + uri = URI.create(url); + } catch (IllegalArgumentException e) { + throw new IdentityFetchError("discovered endpoint is not a valid URL: " + url); + } + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + if (scheme.equals("https")) { + return url; + } + if (scheme.equals("http") && LOCAL_HOSTS.contains(hostOf(uri))) { + return url; + } + throw new IdentityFetchError("discovered endpoint must use https: " + url); + } + + private static String hostOf(URI uri) { + String host = uri.getHost(); + if (host == null) { + return ""; + } + if (host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); + } + return host.toLowerCase(Locale.ROOT); + } + + private static String stringField(Map map, String key) { + Object value = map.get(key); + return value instanceof String s ? s : null; + } + + private static String stringClaim(Map claims, String key) { + Object value = claims.get(key); + return value instanceof String s ? s : null; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/GenericOidcProvider.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/GenericOidcProvider.java new file mode 100644 index 00000000..9fd0503e --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/GenericOidcProvider.java @@ -0,0 +1,50 @@ +package org.byteveda.taskito.dashboard.auth.oauth.provider; + +import java.net.http.HttpClient; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig.OidcConfig; +import org.byteveda.taskito.dashboard.auth.oauth.model.ProviderIdentity; + +/** A generic OIDC provider (Okta, Auth0, Keycloak, Entra, …) driven by discovery. */ +final class GenericOidcProvider extends AbstractOidcProvider { + private final OidcConfig config; + + GenericOidcProvider(OidcConfig config, HttpClient http) { + super(http); + this.config = config; + } + + @Override + public String slot() { + return config.slot(); + } + + @Override + public String label() { + return config.label(); + } + + @Override + public String type() { + return "oidc"; + } + + @Override + protected String clientId() { + return config.clientId(); + } + + @Override + protected String clientSecret() { + return config.clientSecret(); + } + + @Override + protected String discoveryUrl() { + return config.discoveryUrl(); + } + + @Override + public void checkAllowlist(ProviderIdentity identity) { + checkDomainAllowlist(config.allowedDomains(), identity); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/GitHubProvider.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/GitHubProvider.java new file mode 100644 index 00000000..a348b61f --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/GitHubProvider.java @@ -0,0 +1,173 @@ +package org.byteveda.taskito.dashboard.auth.oauth.provider; + +import java.net.http.HttpClient; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig.GitHubConfig; +import org.byteveda.taskito.dashboard.auth.oauth.error.AllowlistDenied; +import org.byteveda.taskito.dashboard.auth.oauth.error.IdentityFetchError; +import org.byteveda.taskito.dashboard.auth.oauth.model.OAuthState; +import org.byteveda.taskito.dashboard.auth.oauth.model.ProviderIdentity; +import org.byteveda.taskito.dashboard.support.Json; + +/** + * GitHub login over plain OAuth2 (GitHub does not implement OIDC, so there is no + * id-token and no {@link IdTokenValidator}). PKCE is honoured. Org-membership + * allowlisting is enforced inside {@link #exchangeCode} because it needs the + * access token, leaving {@link #checkAllowlist} a no-op. + */ +final class GitHubProvider implements OAuthProvider { + private static final String AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; + private static final String TOKEN_URL = "https://github.com/login/oauth/access_token"; + private static final String API_BASE = "https://api.github.com"; + private static final String SCOPE = "read:user user:email"; + + private final GitHubConfig config; + private final HttpClient http; + + GitHubProvider(GitHubConfig config, HttpClient http) { + this.config = config; + this.http = http; + } + + @Override + public String slot() { + return "github"; + } + + @Override + public String label() { + return "GitHub"; + } + + @Override + public String type() { + return "github"; + } + + @Override + public String authorizationUrl(OAuthState state, String redirectUri) { + // GitHub is not OIDC: nonce is unused. read:org is added so the membership + // endpoint returns reliable results when an org allowlist is configured. + String scope = config.allowedOrgs().isEmpty() ? SCOPE : SCOPE + " read:org"; + Map params = new LinkedHashMap<>(); + params.put("client_id", config.clientId()); + params.put("redirect_uri", redirectUri); + params.put("scope", scope); + params.put("state", state.state()); + params.put("code_challenge", Pkce.challenge(state.codeVerifier())); + params.put("code_challenge_method", Pkce.METHOD); + params.put("allow_signup", "false"); + return AUTHORIZE_URL + "?" + OAuthHttp.formEncode(params); + } + + @Override + public ProviderIdentity exchangeCode(String code, String codeVerifier, String redirectUri, String expectedNonce) { + String accessToken = fetchAccessToken(code, codeVerifier, redirectUri); + Map user = fetchUser(accessToken); + Object id = user.get("id"); + Object login = user.get("login"); + if (id == null || !(login instanceof String loginName) || loginName.isEmpty()) { + throw new IdentityFetchError("GitHub /user response missing 'id' or 'login'"); + } + String email = primaryVerifiedEmail(accessToken); + // Membership needs the access token, so enforce it here (not checkAllowlist). + verifyOrgMembership(accessToken, loginName); + return new ProviderIdentity( + slot(), + String.valueOf(id), + email, + email != null, + stringValue(user.get("name"), loginName), + asString(user.get("avatar_url"))); + } + + private String fetchAccessToken(String code, String codeVerifier, String redirectUri) { + Map form = new LinkedHashMap<>(); + form.put("grant_type", "authorization_code"); + form.put("code", code); + form.put("redirect_uri", redirectUri); + form.put("code_verifier", codeVerifier); + form.put("client_id", config.clientId()); + form.put("client_secret", config.clientSecret()); + OAuthHttp.HttpResult result = OAuthHttp.postForm(http, TOKEN_URL, form, Map.of()); + if (result.status() >= 400) { + throw new IdentityFetchError("GitHub token exchange failed with status " + result.status()); + } + Map token = Json.parseMap(result.body()); + Object accessToken = token == null ? null : token.get("access_token"); + if (!(accessToken instanceof String value) || value.isEmpty()) { + throw new IdentityFetchError("no access_token in GitHub token response"); + } + return value; + } + + private Map fetchUser(String accessToken) { + OAuthHttp.HttpResult result = OAuthHttp.get(http, API_BASE + "/user", apiHeaders(accessToken)); + if (result.status() != 200) { + throw new IdentityFetchError("GitHub GET /user failed with status " + result.status()); + } + Map user = Json.parseMap(result.body()); + if (user == null) { + throw new IdentityFetchError("GitHub /user returned a non-JSON body"); + } + return user; + } + + /** The primary verified email, or {@code null} — an unverified email is never trusted. */ + private String primaryVerifiedEmail(String accessToken) { + OAuthHttp.HttpResult result = OAuthHttp.get(http, API_BASE + "/user/emails", apiHeaders(accessToken)); + if (result.status() != 200) { + return null; + } + for (Map entry : Json.parseListOfObjects(result.body())) { + if (Boolean.TRUE.equals(entry.get("primary")) && Boolean.TRUE.equals(entry.get("verified"))) { + return asString(entry.get("email")); + } + } + return null; + } + + private void verifyOrgMembership(String accessToken, String login) { + List allowedOrgs = config.allowedOrgs(); + if (allowedOrgs.isEmpty()) { + return; + } + for (String org : allowedOrgs) { + String url = API_BASE + "/orgs/" + org + "/members/" + login; + OAuthHttp.HttpResult result = OAuthHttp.get(http, url, apiHeaders(accessToken)); + if (result.status() == 204) { + return; + } + // 404 = not a member; 302 = requester cannot see membership. Anything + // else is an unexpected API error. + if (result.status() != 404 && result.status() != 302) { + throw new IdentityFetchError("GitHub org membership check failed with status " + result.status()); + } + } + throw new AllowlistDenied("user is not a member of any allowed GitHub org"); + } + + @Override + public void checkAllowlist(ProviderIdentity identity) { + // GitHub's org check runs inside exchangeCode; required by the contract. + } + + private static Map apiHeaders(String accessToken) { + Map headers = new LinkedHashMap<>(); + headers.put("Authorization", "Bearer " + accessToken); + headers.put("Accept", "application/vnd.github+json"); + headers.put("X-GitHub-Api-Version", "2022-11-28"); + return headers; + } + + private static String stringValue(Object value, String fallback) { + String text = asString(value); + return text != null && !text.isEmpty() ? text : fallback; + } + + private static String asString(Object value) { + return value instanceof String s ? s : null; + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/GoogleProvider.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/GoogleProvider.java new file mode 100644 index 00000000..7374686c --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/GoogleProvider.java @@ -0,0 +1,66 @@ +package org.byteveda.taskito.dashboard.auth.oauth.provider; + +import java.net.http.HttpClient; +import java.util.LinkedHashMap; +import java.util.Map; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig.GoogleConfig; +import org.byteveda.taskito.dashboard.auth.oauth.model.ProviderIdentity; + +/** Google Sign-In over OIDC, with optional Google-Workspace domain allowlisting. */ +final class GoogleProvider extends AbstractOidcProvider { + private static final String DISCOVERY_URL = "https://accounts.google.com/.well-known/openid-configuration"; + + private final GoogleConfig config; + + GoogleProvider(GoogleConfig config, HttpClient http) { + super(http); + this.config = config; + } + + @Override + public String slot() { + return "google"; + } + + @Override + public String label() { + return "Google"; + } + + @Override + public String type() { + return "google"; + } + + @Override + protected String clientId() { + return config.clientId(); + } + + @Override + protected String clientSecret() { + return config.clientSecret(); + } + + @Override + protected String discoveryUrl() { + return DISCOVERY_URL; + } + + @Override + protected Map extraAuthParams() { + Map params = new LinkedHashMap<>(); + params.put("prompt", "select_account"); + // A single allowed domain is passed as a UX hint so Google pre-selects the + // right account; enforcement still happens in checkAllowlist. + if (config.allowedDomains().size() == 1) { + params.put("hd", config.allowedDomains().get(0)); + } + return params; + } + + @Override + public void checkAllowlist(ProviderIdentity identity) { + checkDomainAllowlist(config.allowedDomains(), identity); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/IdTokenValidator.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/IdTokenValidator.java new file mode 100644 index 00000000..af6ac6c3 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/IdTokenValidator.java @@ -0,0 +1,145 @@ +package org.byteveda.taskito.dashboard.auth.oauth.provider; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.text.ParseException; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.byteveda.taskito.dashboard.auth.oauth.error.IdentityFetchError; + +/** + * OIDC id-token verification: signature, then the OIDC claim checks. + * + *

This is the only class that touches nimbus-jose-jwt, so when the optional + * jar is absent the {@link NoClassDefFoundError} surfaces the moment an OIDC + * provider is constructed (it holds one of these) and the dashboard can degrade + * to password-only auth. Constructing an instance eagerly references a nimbus + * type so that failure happens at flow-build time, not mid-login. + * + *

Security invariants enforced here: + *

+ */ +public final class IdTokenValidator { + private static final long CLOCK_SKEW_SECONDS = 60; + + private final Set algorithms = Set.of(JWSAlgorithm.RS256, JWSAlgorithm.ES256); + private final ConcurrentHashMap> jwkSources = new ConcurrentHashMap<>(); + + /** + * Verify {@code idToken} and return its claims. + * + * @param issuer expected {@code iss}; skipped when {@code null} + * @param expectedNonce expected {@code nonce}; skipped when {@code null} + * @param nowSeconds current Unix time in seconds (injected for testability) + * @throws IdentityFetchError if the signature or any claim check fails + */ + public Map validate( + String idToken, String jwksUri, String issuer, String clientId, String expectedNonce, long nowSeconds) { + JWTClaimsSet claims = verifySignature(idToken, jwksUri); + checkIssuer(claims, issuer); + checkAudience(claims, clientId); + checkExpiry(claims, nowSeconds); + checkNonce(claims, expectedNonce); + checkSubject(claims); + return claims.getClaims(); + } + + private JWTClaimsSet verifySignature(String idToken, String jwksUri) { + ConfigurableJWTProcessor processor = new DefaultJWTProcessor<>(); + processor.setJWSKeySelector(new JWSVerificationKeySelector<>(algorithms, jwkSourceFor(jwksUri))); + // Do the claim checks ourselves so failures carry a precise message; the + // processor is left to verify only the signature and algorithm. + processor.setJWTClaimsSetVerifier((claimsSet, context) -> {}); + try { + return processor.process(idToken, null); + } catch (BadJOSEException | JOSEException e) { + throw new IdentityFetchError("id_token signature validation failed: " + e.getMessage(), e); + } catch (ParseException e) { + throw new IdentityFetchError("id_token could not be parsed: " + e.getMessage(), e); + } + } + + private JWKSource jwkSourceFor(String jwksUri) { + return jwkSources.computeIfAbsent(jwksUri, IdTokenValidator::buildJwkSource); + } + + private static JWKSource buildJwkSource(String jwksUri) { + try { + // URI.create(..).toURL() avoids the URL(String) constructor deprecated since Java 20. + URL url = URI.create(jwksUri).toURL(); + return JWKSourceBuilder.create(url).retrying(true).build(); + } catch (MalformedURLException | IllegalArgumentException e) { + throw new IdentityFetchError("invalid jwks_uri: " + jwksUri, e); + } + } + + private static void checkIssuer(JWTClaimsSet claims, String issuer) { + if (issuer != null && !issuer.equals(claims.getIssuer())) { + throw new IdentityFetchError( + "id_token issuer mismatch: expected " + issuer + ", got " + claims.getIssuer()); + } + } + + private static void checkAudience(JWTClaimsSet claims, String clientId) { + List aud = claims.getAudience(); + if (aud == null || !aud.contains(clientId)) { + throw new IdentityFetchError("id_token audience does not contain client id"); + } + if (aud.size() > 1) { + // Multi-audience tokens must name the authorised party explicitly. + Object azp = claims.getClaim("azp"); + if (!clientId.equals(azp)) { + throw new IdentityFetchError("id_token azp does not match client id for multi-audience token"); + } + } + } + + private static void checkExpiry(JWTClaimsSet claims, long nowSeconds) { + Date exp = claims.getExpirationTime(); + if (exp == null) { + throw new IdentityFetchError("id_token missing exp claim"); + } + long expSeconds = exp.getTime() / 1000; + if (expSeconds < nowSeconds - CLOCK_SKEW_SECONDS) { + throw new IdentityFetchError("id_token expired"); + } + } + + private static void checkNonce(JWTClaimsSet claims, String expectedNonce) { + if (expectedNonce == null) { + return; + } + Object nonce = claims.getClaim("nonce"); + if (!expectedNonce.equals(nonce)) { + throw new IdentityFetchError("id_token nonce mismatch"); + } + } + + private static void checkSubject(JWTClaimsSet claims) { + String sub = claims.getSubject(); + if (sub == null || sub.isEmpty()) { + throw new IdentityFetchError("id_token missing sub claim"); + } + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/OAuthHttp.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/OAuthHttp.java new file mode 100644 index 00000000..5505d3cc --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/OAuthHttp.java @@ -0,0 +1,71 @@ +package org.byteveda.taskito.dashboard.auth.oauth.provider; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.StringJoiner; +import org.byteveda.taskito.dashboard.auth.oauth.error.IdentityFetchError; + +/** + * The tiny slice of HTTP the providers need: form-urlencoded POST and header-only + * GET, over an injected {@link HttpClient} (a real one in production, a fake one + * in tests) so the provider layer never hits the network under test. + * + *

Every call returns the raw status + body as an {@link HttpResult}; callers + * decide what a non-2xx means. IO/interrupt failures become + * {@link IdentityFetchError} so the flow layer surfaces a single error type. + */ +final class OAuthHttp { + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private OAuthHttp() {} + + /** A raw HTTP response: status code plus the body as text. */ + record HttpResult(int status, String body) {} + + static HttpResult get(HttpClient http, String url, Map headers) { + HttpRequest.Builder builder = + HttpRequest.newBuilder(URI.create(url)).timeout(TIMEOUT).GET(); + headers.forEach(builder::header); + return send(http, builder); + } + + static HttpResult postForm(HttpClient http, String url, Map form, Map headers) { + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)) + .timeout(TIMEOUT) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(formEncode(form))); + headers.forEach(builder::header); + return send(http, builder); + } + + static String formEncode(Map params) { + StringJoiner joiner = new StringJoiner("&"); + for (Map.Entry entry : params.entrySet()) { + joiner.add(encode(entry.getKey()) + "=" + encode(entry.getValue())); + } + return joiner.toString(); + } + + private static HttpResult send(HttpClient http, HttpRequest.Builder builder) { + try { + HttpResponse response = http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + return new HttpResult(response.statusCode(), response.body()); + } catch (java.io.IOException e) { + throw new IdentityFetchError("HTTP request failed: " + e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IdentityFetchError("HTTP request interrupted", e); + } + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/OAuthProvider.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/OAuthProvider.java new file mode 100644 index 00000000..dd1159ab --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/OAuthProvider.java @@ -0,0 +1,34 @@ +package org.byteveda.taskito.dashboard.auth.oauth.provider; + +import org.byteveda.taskito.dashboard.auth.oauth.model.OAuthState; +import org.byteveda.taskito.dashboard.auth.oauth.model.ProviderIdentity; + +/** + * Contract every concrete provider (Google, GitHub, generic OIDC) satisfies. + * + *

The split between {@link #exchangeCode} (network IO + claim normalisation) + * and {@link #checkAllowlist} (pure-data permission check) is deliberate so + * tests can drive either path in isolation. GitHub is the exception: its org + * membership needs the access token, so it enforces the allowlist inside + * {@code exchangeCode} and leaves {@code checkAllowlist} a no-op. + */ +public interface OAuthProvider { + + /** URL-safe registry key used in the callback path ({@code google}, …). */ + String slot(); + + /** Human-readable button label rendered by the dashboard. */ + String label(); + + /** One of {@code "google"}, {@code "github"}, {@code "oidc"} — picks the icon. */ + String type(); + + /** Build the provider-side authorize URL the browser is redirected to. */ + String authorizationUrl(OAuthState state, String redirectUri); + + /** Exchange the auth code for an identity, raising on any failure. */ + ProviderIdentity exchangeCode(String code, String codeVerifier, String redirectUri, String expectedNonce); + + /** Raise {@code AllowlistDenied} if the identity is not permitted. */ + void checkAllowlist(ProviderIdentity identity); +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/Pkce.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/Pkce.java new file mode 100644 index 00000000..08396a5d --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/Pkce.java @@ -0,0 +1,44 @@ +package org.byteveda.taskito.dashboard.auth.oauth.provider; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import org.byteveda.taskito.dashboard.auth.Tokens; + +/** + * PKCE (RFC 7636) code-verifier / S256 code-challenge derivation. + * + *

A fresh high-entropy verifier is minted per flow and stashed server-side; + * only its {@code S256} challenge travels to the provider. The verifier is + * replayed on the token exchange, binding the auth code to this browser and + * defeating code-interception attacks. + */ +public final class Pkce { + /** Always {@code S256} — the only method OAuth providers uniformly support. */ + public static final String METHOD = "S256"; + + private Pkce() {} + + /** A 43-char base64url verifier (32 random bytes), above the RFC 7636 minimum. */ + public static String verifier() { + return Tokens.urlSafe(Tokens.CODE_VERIFIER_BYTES); + } + + /** + * The {@code S256} challenge for {@code verifier}: base64url (no padding) of + * {@code SHA-256(verifier)}, matching every provider's PKCE implementation. + */ + public static String challenge(String verifier) { + byte[] digest = sha256(verifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } + + private static byte[] sha256(byte[] input) { + try { + return MessageDigest.getInstance("SHA-256").digest(input); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/Providers.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/Providers.java new file mode 100644 index 00000000..5fb5da97 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/Providers.java @@ -0,0 +1,42 @@ +package org.byteveda.taskito.dashboard.auth.oauth.provider; + +import java.net.http.HttpClient; +import java.util.LinkedHashMap; +import java.util.Map; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig.GitHubConfig; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig.GoogleConfig; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig.OidcConfig; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig.ProviderConfig; + +/** + * Factory for the concrete provider implementations. Keeping it in this package + * lets Google/GitHub/OIDC providers stay package-private while the orchestration + * layer builds them by config type through one public entry point. + */ +public final class Providers { + + private Providers() {} + + /** Instantiate one provider per configured slot, keyed by slot, in display order. */ + public static Map build(OAuthConfig config, HttpClient http) { + Map registry = new LinkedHashMap<>(); + for (ProviderConfig entry : config.providers()) { + registry.put(entry.slot(), build(entry, http)); + } + return registry; + } + + private static OAuthProvider build(ProviderConfig entry, HttpClient http) { + if (entry instanceof GoogleConfig google) { + return new GoogleProvider(google, http); + } + if (entry instanceof GitHubConfig github) { + return new GitHubProvider(github, http); + } + if (entry instanceof OidcConfig oidc) { + return new GenericOidcProvider(oidc, http); + } + throw new IllegalStateException("unknown provider config: " + entry); + } +} diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/package-info.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/package-info.java new file mode 100644 index 00000000..6b6f5c82 --- /dev/null +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/package-info.java @@ -0,0 +1,2 @@ +/** OAuth provider implementations (Google, GitHub, generic OIDC) and their IO. */ +package org.byteveda.taskito.dashboard.auth.oauth.provider; diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/Json.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/Json.java index 8ece2ca1..f51a189c 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/Json.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/support/Json.java @@ -78,6 +78,29 @@ public static List parseStringList(String json) { } } + /** Parse a JSON array of objects into maps; empty list if malformed/non-array/null. */ + public static List> parseListOfObjects(String json) { + if (json == null || json.isEmpty()) { + return List.of(); + } + try { + JsonNode node = MAPPER.readTree(json); + if (node == null || !node.isArray()) { + return List.of(); + } + List> out = new ArrayList<>(node.size()); + for (JsonNode element : node) { + Map map = asMap(element); + if (map != null) { + out.add(map); + } + } + return out; + } catch (IOException e) { + return List.of(); + } + } + private static Map asMap(JsonNode node) { if (node == null || !node.isObject()) { return null; diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardOAuthTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardOAuthTest.java new file mode 100644 index 00000000..bbc715bc --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardOAuthTest.java @@ -0,0 +1,82 @@ +package org.byteveda.taskito.dashboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.byteveda.taskito.Taskito; +import org.byteveda.taskito.dashboard.auth.AuthStore; +import org.byteveda.taskito.dashboard.auth.oauth.OAuthFlow; +import org.byteveda.taskito.dashboard.auth.oauth.OAuthStateStore; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig; +import org.byteveda.taskito.dashboard.auth.oauth.provider.OAuthProvider; +import org.byteveda.taskito.dashboard.oauth.FakeOAuthProvider; +import org.byteveda.taskito.dashboard.store.SettingsAccess; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; + +/** Wires an OAuth flow into the live dashboard server and exercises the public routes. */ +@Timeout(30) +class DashboardOAuthTest { + + private static Taskito open(Path dir) { + return Taskito.builder().sqlite(dir.resolve("t.db").toString()).open(); + } + + private static OAuthFlow fakeFlow(Taskito queue) { + OAuthConfig config = new OAuthConfig("https://dash.example", null, null, List.of(), true, List.of()); + SettingsAccess settings = SettingsAccess.of(queue); + Map providers = Map.of("fake", new FakeOAuthProvider("fake", "Fake", "oidc")); + return new OAuthFlow(new AuthStore(settings), config, new OAuthStateStore(settings), providers); + } + + private static HttpResponse get(int port, String path) throws Exception { + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .GET() + .build(); + return client.send(request, BodyHandlers.ofString()); + } + + @Test + void providersEndpointListsConfiguredProvider(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.startWithOAuth(queue, 0, false, fakeFlow(queue))) { + HttpResponse resp = get(server.port(), "/api/auth/providers"); + assertEquals(200, resp.statusCode()); + assertTrue(resp.body().contains("\"password_enabled\":true")); + assertTrue(resp.body().contains("\"slot\":\"fake\"")); + assertTrue(resp.body().contains("\"label\":\"Fake\"")); + } + } + + @Test + void startRedirectsToProviderAuthorizeUrl(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.startWithOAuth(queue, 0, false, fakeFlow(queue))) { + HttpResponse resp = get(server.port(), "/api/auth/oauth/start/fake"); + assertEquals(302, resp.statusCode()); + assertTrue( + resp.headers().firstValue("Location").orElse("").startsWith("https://provider.example/authorize")); + } + } + + @Test + void startUnknownSlotReports404(@TempDir Path dir) throws Exception { + try (Taskito queue = open(dir); + DashboardServer server = DashboardServer.startWithOAuth(queue, 0, false, fakeFlow(queue))) { + assertEquals( + 404, get(server.port(), "/api/auth/oauth/start/missing").statusCode()); + } + } +} diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/FakeOAuthProvider.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/FakeOAuthProvider.java new file mode 100644 index 00000000..1028a77e --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/FakeOAuthProvider.java @@ -0,0 +1,81 @@ +package org.byteveda.taskito.dashboard.oauth; + +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; + +/** + * A network-free {@link OAuthProvider} for flow/integration tests. It records the + * state it saw, returns a canned identity, and can be told to fail either the + * code exchange or the allowlist check. + */ +public final class FakeOAuthProvider implements OAuthProvider { + private final String slot; + private final String label; + private final String type; + private ProviderIdentity identity; + private RuntimeException exchangeError; + private RuntimeException allowlistError; + + public OAuthState lastState; + public String lastRedirectUri; + + public FakeOAuthProvider(String slot, String label, String type) { + this.slot = slot; + this.label = label; + this.type = type; + this.identity = new ProviderIdentity(slot, "subject-1", "user@example.com", true, "Test User", null); + } + + public FakeOAuthProvider identity(ProviderIdentity value) { + this.identity = value; + return this; + } + + public FakeOAuthProvider failExchange(RuntimeException error) { + this.exchangeError = error; + return this; + } + + public FakeOAuthProvider denyAllowlist(RuntimeException error) { + this.allowlistError = error; + return this; + } + + @Override + public String slot() { + return slot; + } + + @Override + public String label() { + return label; + } + + @Override + public String type() { + return type; + } + + @Override + public String authorizationUrl(OAuthState state, String redirectUri) { + this.lastState = state; + this.lastRedirectUri = redirectUri; + return "https://provider.example/authorize?state=" + state.state(); + } + + @Override + public ProviderIdentity exchangeCode(String code, String codeVerifier, String redirectUri, String expectedNonce) { + if (exchangeError != null) { + throw exchangeError; + } + return identity; + } + + @Override + public void checkAllowlist(ProviderIdentity id) { + if (allowlistError != null) { + throw allowlistError; + } + } +} diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthConfigTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthConfigTest.java new file mode 100644 index 00000000..5eec6457 --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthConfigTest.java @@ -0,0 +1,110 @@ +package org.byteveda.taskito.dashboard.oauth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig.GitHubConfig; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig.GoogleConfig; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig.OidcConfig; +import org.byteveda.taskito.dashboard.auth.oauth.error.OAuthConfigError; +import org.junit.jupiter.api.Test; + +class OAuthConfigTest { + + @Test + void emptyWhenNothingConfigured() { + assertTrue(OAuthConfig.fromEnv(Map.of()).isEmpty()); + } + + @Test + void providerWithoutBaseUrlFailsFast() { + Map env = + Map.of(OAuthConfig.ENV_GOOGLE_CLIENT_ID, "gid", OAuthConfig.ENV_GOOGLE_CLIENT_SECRET, "gsec"); + assertThrows(OAuthConfigError.class, () -> OAuthConfig.fromEnv(env)); + } + + @Test + void googleWithoutSecretFails() { + Map env = Map.of( + OAuthConfig.ENV_REDIRECT_BASE_URL, "https://dash.example", + OAuthConfig.ENV_GOOGLE_CLIENT_ID, "gid"); + assertThrows(OAuthConfigError.class, () -> OAuthConfig.fromEnv(env)); + } + + @Test + void parsesGoogleAndAdminEmails() { + Map env = Map.of( + OAuthConfig.ENV_REDIRECT_BASE_URL, "https://dash.example/", + OAuthConfig.ENV_GOOGLE_CLIENT_ID, "gid", + OAuthConfig.ENV_GOOGLE_CLIENT_SECRET, "gsec", + OAuthConfig.ENV_GOOGLE_ALLOWED_DOMAINS, "acme.com, corp.example", + OAuthConfig.ENV_ADMIN_EMAILS, "boss@acme.com"); + OAuthConfig config = OAuthConfig.fromEnv(env).orElseThrow(); + assertTrue(config.isEnabled()); + assertEquals(List.of("acme.com", "corp.example"), config.google().allowedDomains()); + assertEquals(List.of("boss@acme.com"), config.adminEmails()); + // Trailing slash on the base URL is stripped before the callback path. + assertEquals("https://dash.example/api/auth/oauth/callback/google", config.callbackUrl("google")); + } + + @Test + void parsesOidcSlot() { + Map env = Map.of( + OAuthConfig.ENV_REDIRECT_BASE_URL, + "https://dash.example", + OAuthConfig.ENV_OIDC_PROVIDERS, + "corp", + "TASKITO_DASHBOARD_OAUTH_OIDC_CORP_CLIENT_ID", + "cid", + "TASKITO_DASHBOARD_OAUTH_OIDC_CORP_CLIENT_SECRET", + "csec", + "TASKITO_DASHBOARD_OAUTH_OIDC_CORP_DISCOVERY_URL", + "https://idp.example/.well-known/openid-configuration"); + OAuthConfig config = OAuthConfig.fromEnv(env).orElseThrow(); + assertEquals(1, config.oidc().size()); + OidcConfig oidc = config.oidc().get(0); + assertEquals("corp", oidc.slot()); + assertEquals("Corp", oidc.label()); // title-cased default + } + + @Test + void rejectsReservedAndMalformedOidcSlots() { + assertThrows( + OAuthConfigError.class, + () -> new OidcConfig("google", "id", "sec", "https://idp/.well-known", List.of(), "G")); + assertThrows( + OAuthConfigError.class, + () -> new OidcConfig("Bad Slot", "id", "sec", "https://idp/.well-known", List.of(), "x")); + } + + @Test + void redirectUrlAllowsHttpOnlyForLocalhost() { + assertThrows(OAuthConfigError.class, () -> config("http://example.com")); + assertFalse(config("http://localhost:8080").isEnabled()); // valid, just no providers + assertFalse(config("https://example.com").isEnabled()); + } + + @Test + void providersListingIsOrderedGoogleGithubOidc() { + OAuthConfig config = new OAuthConfig( + "https://dash.example", + new GoogleConfig("gid", "gsec", List.of()), + new GitHubConfig("ghid", "ghsec", List.of()), + List.of(new OidcConfig("corp", "cid", "csec", "https://idp/.well-known", List.of(), "Corp")), + true, + List.of()); + List slots = config.providersListing().stream() + .map(p -> (String) p.get("slot")) + .toList(); + assertEquals(List.of("google", "github", "corp"), slots); + } + + private static OAuthConfig config(String baseUrl) { + return new OAuthConfig(baseUrl, null, null, List.of(), true, List.of()); + } +} diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthFlowTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthFlowTest.java new file mode 100644 index 00000000..71eb417e --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthFlowTest.java @@ -0,0 +1,95 @@ +package org.byteveda.taskito.dashboard.oauth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.byteveda.taskito.dashboard.InMemorySettings; +import org.byteveda.taskito.dashboard.auth.AuthStore; +import org.byteveda.taskito.dashboard.auth.oauth.OAuthFlow; +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.AllowlistDenied; +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.provider.OAuthProvider; +import org.junit.jupiter.api.Test; + +class OAuthFlowTest { + + private final InMemorySettings settings = new InMemorySettings(); + private final AuthStore authStore = new AuthStore(settings); + private final OAuthConfig config = new OAuthConfig("https://dash.example", null, null, List.of(), true, List.of()); + private final FakeOAuthProvider fake = new FakeOAuthProvider("fake", "Fake", "oidc"); + private final OAuthFlow flow = flowWith(fake); + + private OAuthFlow flowWith(FakeOAuthProvider provider) { + Map providers = Map.of(provider.slot(), provider); + return new OAuthFlow(authStore, config, new OAuthStateStore(settings), providers); + } + + @Test + void startStashesStateAndBuildsAuthorizeUrl() { + String url = flow.start("fake", "/dashboard"); + assertTrue(url.startsWith("https://provider.example/authorize?state=")); + assertNotNull(fake.lastState); + assertEquals("/dashboard", fake.lastState.nextUrl()); + assertEquals("https://dash.example/api/auth/oauth/callback/fake", fake.lastRedirectUri); + } + + @Test + void startSanitizesUnsafeNext() { + flow.start("fake", "//evil.com"); + assertEquals("/", fake.lastState.nextUrl()); + } + + @Test + void startRejectsUnknownProvider() { + assertThrows(ProviderNotConfigured.class, () -> flow.start("missing", "/")); + } + + @Test + void handleCallbackLandsSessionAndMapsFirstUserToAdmin() { + flow.start("fake", "/next"); + String state = fake.lastState.state(); + OAuthFlow.CallbackResult result = flow.handleCallback("fake", "code", state, null); + assertEquals("fake:subject-1", result.session().username()); + assertEquals("admin", result.session().role()); // first verified user + assertEquals("/next", result.nextUrl()); + assertTrue(authStore.getUser("fake:subject-1").isPresent()); + } + + @Test + void handleCallbackStateIsSingleUse() { + flow.start("fake", "/"); + String state = fake.lastState.state(); + flow.handleCallback("fake", "code", state, null); + assertThrows(StateValidationError.class, () -> flow.handleCallback("fake", "code", state, null)); + } + + @Test + void handleCallbackRejectsMissingParamsAndProviderError() { + assertThrows(StateValidationError.class, () -> flow.handleCallback("fake", null, null, null)); + assertThrows(IdentityFetchError.class, () -> flow.handleCallback("fake", null, null, "access_denied")); + } + + @Test + void handleCallbackRejectsSlotMismatch() { + flow.start("fake", "/"); + String state = fake.lastState.state(); + // A state minted for "fake" cannot be redeemed at a different slot. + assertThrows(StateValidationError.class, () -> flow.handleCallback("other", "code", state, null)); + } + + @Test + void handleCallbackPropagatesAllowlistDenial() { + fake.denyAllowlist(new AllowlistDenied("outside allowlist")); + flow.start("fake", "/"); + String state = fake.lastState.state(); + assertThrows(AllowlistDenied.class, () -> flow.handleCallback("fake", "code", state, null)); + } +} diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthStateStoreTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthStateStoreTest.java new file mode 100644 index 00000000..1ca4f17d --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthStateStoreTest.java @@ -0,0 +1,79 @@ +package org.byteveda.taskito.dashboard.oauth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import org.byteveda.taskito.dashboard.InMemorySettings; +import org.byteveda.taskito.dashboard.auth.oauth.OAuthStateStore; +import org.byteveda.taskito.dashboard.auth.oauth.model.OAuthState; +import org.byteveda.taskito.dashboard.support.Json; +import org.junit.jupiter.api.Test; + +class OAuthStateStoreTest { + + private final InMemorySettings settings = new InMemorySettings(); + private final OAuthStateStore store = new OAuthStateStore(settings); + + @Test + void createThenConsumeRoundTrips() { + OAuthState created = store.create("google", "/dashboard"); + Optional consumed = store.consume(created.state()); + assertTrue(consumed.isPresent()); + OAuthState row = consumed.get(); + assertEquals(created.state(), row.state()); + assertEquals(created.nonce(), row.nonce()); + assertEquals(created.codeVerifier(), row.codeVerifier()); + assertEquals("google", row.slot()); + assertEquals("/dashboard", row.nextUrl()); + } + + @Test + void consumeIsSingleUse() { + OAuthState created = store.create("github", "/"); + assertTrue(store.consume(created.state()).isPresent()); + // Replay of the same state finds nothing — the row was deleted on first use. + assertTrue(store.consume(created.state()).isEmpty()); + } + + @Test + void expiredStateIsNotReturned() { + long past = System.currentTimeMillis() / 1000 - 10; + Map row = new LinkedHashMap<>(); + row.put("nonce", "n"); + row.put("code_verifier", "v"); + row.put("slot", "google"); + row.put("next_url", "/"); + row.put("created_at", past - 300); + row.put("expires_at", past); + settings.setSetting(OAuthStateStore.STATE_PREFIX + "expired", Json.toString(row)); + + assertTrue(store.consume("expired").isEmpty()); + // The lookup still deletes the row (delete-before-parse). + assertFalse( + settings.getSetting(OAuthStateStore.STATE_PREFIX + "expired").isPresent()); + } + + @Test + void malformedRowYieldsEmpty() { + settings.setSetting(OAuthStateStore.STATE_PREFIX + "bad", "not-json"); + assertTrue(store.consume("bad").isEmpty()); + assertTrue(store.consume(null).isEmpty()); + assertTrue(store.consume("missing").isEmpty()); + } + + @Test + void pruneExpiredRemovesOnlyStaleRows() { + store.create("google", "/"); // fresh, TTL 300s + long past = System.currentTimeMillis() / 1000 - 10; + Map stale = new LinkedHashMap<>(); + stale.put("expires_at", past); + settings.setSetting(OAuthStateStore.STATE_PREFIX + "stale", Json.toString(stale)); + + assertEquals(1, store.pruneExpired()); + assertFalse(settings.getSetting(OAuthStateStore.STATE_PREFIX + "stale").isPresent()); + } +} diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OidcRoundTripTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OidcRoundTripTest.java new file mode 100644 index 00000000..5c0d22e3 --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OidcRoundTripTest.java @@ -0,0 +1,187 @@ +package org.byteveda.taskito.dashboard.oauth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import org.byteveda.taskito.dashboard.auth.oauth.OAuthFlow; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig; +import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig.OidcConfig; +import org.byteveda.taskito.dashboard.auth.oauth.error.IdentityFetchError; +import org.byteveda.taskito.dashboard.auth.oauth.model.ProviderIdentity; +import org.byteveda.taskito.dashboard.auth.oauth.provider.IdTokenValidator; +import org.byteveda.taskito.dashboard.auth.oauth.provider.OAuthProvider; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** + * End-to-end OIDC exchange against an in-process fake IdP: a real signed + * id_token is served, validated through nimbus, and normalised to a + * {@link ProviderIdentity}. No network is touched. + */ +@Timeout(30) +class OidcRoundTripTest { + private static final String CLIENT_ID = "corp-client"; + private static final String NONCE = "nonce-abc"; + + private HttpServer server; + private RSAKey signingKey; + private String issuer; + private String jwksUri; + private String discoveryUrl; + private long now; + + @BeforeEach + void startFakeIdp() throws Exception { + now = System.currentTimeMillis() / 1000; + signingKey = new RSAKeyGenerator(2048) + .keyID("test-key") + .keyUse(KeyUse.SIGNATURE) + .algorithm(JWSAlgorithm.RS256) + .generate(); + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + int port = server.getAddress().getPort(); + String base = "http://127.0.0.1:" + port; + issuer = base; + jwksUri = base + "/jwks"; + discoveryUrl = base + "/.well-known/openid-configuration"; + + String discovery = "{\"issuer\":\"" + issuer + "\",\"authorization_endpoint\":\"" + base + + "/authorize\",\"token_endpoint\":\"" + base + "/token\",\"jwks_uri\":\"" + jwksUri + "\"}"; + String jwks = new JWKSet(signingKey.toPublicJWK()).toString(); + String idToken = signToken(issuer, CLIENT_ID, NONCE, now + 3600); + String tokenResponse = "{\"access_token\":\"x\",\"token_type\":\"Bearer\",\"id_token\":\"" + idToken + "\"}"; + + respondWith("/.well-known/openid-configuration", discovery); + respondWith("/jwks", jwks); + respondWith("/token", tokenResponse); + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + } + + @AfterEach + void stopFakeIdp() { + server.stop(0); + } + + @Test + void exchangeCodeReturnsNormalisedIdentity() { + OidcConfig config = new OidcConfig("corp", CLIENT_ID, "secret", discoveryUrl, List.of(), "Corp"); + OAuthConfig oauthConfig = new OAuthConfig("http://localhost", null, null, List.of(config), true, List.of()); + Map providers = OAuthFlow.buildProviders(oauthConfig, HttpClient.newHttpClient()); + + ProviderIdentity identity = + providers.get("corp").exchangeCode("auth-code", "verifier", "http://localhost/cb", NONCE); + + assertEquals("corp", identity.slot()); + assertEquals("user-123", identity.subject()); + assertEquals("a@example.com", identity.email()); + assertTrue(identity.emailVerified()); + assertEquals("Ada", identity.name()); + } + + @Test + void validatorAcceptsAWellFormedToken() { + String token = signToken(issuer, CLIENT_ID, NONCE, now + 3600); + Map claims = new IdTokenValidator().validate(token, jwksUri, issuer, CLIENT_ID, NONCE, now); + assertEquals("user-123", claims.get("sub")); + } + + @Test + void validatorRejectsWrongNonce() { + String token = signToken(issuer, CLIENT_ID, NONCE, now + 3600); + IdTokenValidator validator = new IdTokenValidator(); + assertThrows( + IdentityFetchError.class, () -> validator.validate(token, jwksUri, issuer, CLIENT_ID, "other", now)); + } + + @Test + void validatorRejectsExpiredToken() { + String expired = signToken(issuer, CLIENT_ID, NONCE, now - 3600); + IdTokenValidator validator = new IdTokenValidator(); + assertThrows( + IdentityFetchError.class, () -> validator.validate(expired, jwksUri, issuer, CLIENT_ID, NONCE, now)); + } + + @Test + void validatorRejectsTamperedSignature() { + String token = signToken(issuer, CLIENT_ID, NONCE, now + 3600); + String tampered = tamperSignature(token); + IdTokenValidator validator = new IdTokenValidator(); + assertThrows( + IdentityFetchError.class, () -> validator.validate(tampered, jwksUri, issuer, CLIENT_ID, NONCE, now)); + } + + private String signToken(String iss, String audience, String nonce, long expSeconds) { + try { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(iss) + .subject("user-123") + .audience(audience) + .claim("email", "a@example.com") + .claim("email_verified", true) + .claim("name", "Ada") + .claim("nonce", nonce) + .issueTime(new Date(now * 1000)) + .expirationTime(new Date(expSeconds * 1000)) + .build(); + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("test-key").build(), claims); + jwt.sign(new RSASSASigner(signingKey)); + return jwt.serialize(); + } catch (JOSEException e) { + throw new IllegalStateException("failed to sign test token", e); + } + } + + private static String tamperSignature(String token) { + // Decode the signature, flip a byte, and re-encode so the bytes always + // differ. Flipping the last base64 char instead would sometimes hit its + // non-significant low bits and decode to the SAME signature (a flake). + int dot = token.lastIndexOf('.'); + byte[] signature = Base64.getUrlDecoder().decode(token.substring(dot + 1)); + signature[0] ^= 0x01; + return token.substring(0, dot + 1) + + Base64.getUrlEncoder().withoutPadding().encodeToString(signature); + } + + private void respondWith(String path, String body) { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + server.createContext(path, (HttpExchange exchange) -> { + try (OutputStream out = exchange.getResponseBody()) { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + out.write(bytes); + } catch (IOException ignored) { + // Client hung up; nothing to do in a test IdP. + } finally { + exchange.close(); + } + }); + } +} diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/PkceTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/PkceTest.java new file mode 100644 index 00000000..8dc1f22f --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/PkceTest.java @@ -0,0 +1,32 @@ +package org.byteveda.taskito.dashboard.oauth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import org.byteveda.taskito.dashboard.auth.oauth.provider.Pkce; +import org.junit.jupiter.api.Test; + +class PkceTest { + + @Test + void challengeMatchesRfc7636Example() { + // The canonical RFC 7636 appendix B vector. + String verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assertEquals("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", Pkce.challenge(verifier)); + } + + @Test + void challengeIsDeterministicAndUnpadded() { + String verifier = Pkce.verifier(); + assertEquals(Pkce.challenge(verifier), Pkce.challenge(verifier)); + // base64url of a 32-byte digest is 43 chars with no '=' padding. + assertEquals(43, Pkce.challenge(verifier).length()); + } + + @Test + void verifierIsHighEntropyAndUnique() { + assertEquals(43, Pkce.verifier().length()); + assertNotEquals(Pkce.verifier(), Pkce.verifier()); + assertEquals("S256", Pkce.METHOD); + } +} diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/UrlSafetyTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/UrlSafetyTest.java new file mode 100644 index 00000000..ace3959c --- /dev/null +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/UrlSafetyTest.java @@ -0,0 +1,33 @@ +package org.byteveda.taskito.dashboard.oauth; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.byteveda.taskito.dashboard.auth.oauth.UrlSafety; +import org.junit.jupiter.api.Test; + +class UrlSafetyTest { + + @Test + void acceptsRootedRelativePaths() { + assertTrue(UrlSafety.isSafeRedirect("/dashboard")); + assertTrue(UrlSafety.isSafeRedirect("/jobs/123?tab=logs")); + assertTrue(UrlSafety.isSafeRedirect("/")); + } + + @Test + void rejectsOffOriginTargets() { + assertFalse(UrlSafety.isSafeRedirect("//evil.com")); + assertFalse(UrlSafety.isSafeRedirect("/\\evil")); + assertFalse(UrlSafety.isSafeRedirect("https://x")); + assertFalse(UrlSafety.isSafeRedirect("http://x")); + assertFalse(UrlSafety.isSafeRedirect("javascript:alert(1)")); + } + + @Test + void rejectsEmptyAndNonRooted() { + assertFalse(UrlSafety.isSafeRedirect("")); + assertFalse(UrlSafety.isSafeRedirect(null)); + assertFalse(UrlSafety.isSafeRedirect("dashboard")); + } +} From e6891dbcb691b70dcd041e05572bed7371cdc1fd Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:45:34 +0530 Subject: [PATCH 2/2] fix(java): address dashboard OAuth review Compare the callback slot from the non-null route param (no NPE on a null state slot); gate state-store pruning to once per TTL window instead of an O(n) KV scan on every login; shut down the fake-IdP test executor in teardown. --- .../dashboard/auth/oauth/OAuthFlow.java | 6 ++++-- .../dashboard/auth/oauth/OAuthStateStore.java | 18 ++++++++++++++++++ .../dashboard/oauth/OidcRoundTripTest.java | 6 +++++- 3 files changed, 27 insertions(+), 3 deletions(-) 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 index 92a297ea..d65006ce 100644 --- 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 @@ -104,7 +104,9 @@ public CallbackResult handleCallback(String slot, String code, String stateToken throw new StateValidationError("state is invalid, expired, or already used"); } OAuthState state = row.get(); - if (!state.slot().equals(slot)) { + // 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"); } @@ -121,7 +123,7 @@ public CallbackResult handleCallback(String slot, String code, String stateToken identity.emailVerified(), config.adminEmails()); Session session = authStore.createSession(user.username(), user.role()); - stateStore.pruneExpired(); + stateStore.pruneExpiredIfDue(); return new CallbackResult(session, state.nextUrl()); } diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthStateStore.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthStateStore.java index bf4ea7fd..cea85de7 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthStateStore.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthStateStore.java @@ -3,6 +3,7 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; import org.byteveda.taskito.dashboard.auth.Tokens; import org.byteveda.taskito.dashboard.auth.oauth.model.OAuthState; import org.byteveda.taskito.dashboard.store.SettingsAccess; @@ -27,6 +28,7 @@ public final class OAuthStateStore { public static final long DEFAULT_STATE_TTL_SECONDS = 5 * 60; private final SettingsAccess settings; + private final AtomicLong lastPruneAt = new AtomicLong(0); public OAuthStateStore(SettingsAccess settings) { this.settings = settings; @@ -93,6 +95,22 @@ public Optional consume(String stateToken) { return Optional.of(state); } + /** + * Sweep at most once per TTL window so the O(n) KV scan stays off the + * per-login hot path. Single-use consume + expiry check already bound the + * garbage; this only reclaims rows from abandoned flows. + */ + public void pruneExpiredIfDue() { + long now = nowSeconds(); + long last = lastPruneAt.get(); + if (now - last < DEFAULT_STATE_TTL_SECONDS) { + return; + } + if (lastPruneAt.compareAndSet(last, now)) { + pruneExpired(); + } + } + /** Best-effort sweep of expired state rows. Returns the count removed. */ public int pruneExpired() { long now = nowSeconds(); diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OidcRoundTripTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OidcRoundTripTest.java index 5c0d22e3..7620a474 100644 --- a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OidcRoundTripTest.java +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OidcRoundTripTest.java @@ -25,6 +25,7 @@ import java.util.Date; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.byteveda.taskito.dashboard.auth.oauth.OAuthFlow; import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig; @@ -49,6 +50,7 @@ class OidcRoundTripTest { private static final String NONCE = "nonce-abc"; private HttpServer server; + private ExecutorService executor; private RSAKey signingKey; private String issuer; private String jwksUri; @@ -80,13 +82,15 @@ void startFakeIdp() throws Exception { respondWith("/.well-known/openid-configuration", discovery); respondWith("/jwks", jwks); respondWith("/token", tokenResponse); - server.setExecutor(Executors.newCachedThreadPool()); + executor = Executors.newCachedThreadPool(); + server.setExecutor(executor); server.start(); } @AfterEach void stopFakeIdp() { server.stop(0); + executor.shutdownNow(); } @Test