From 492f0cb6454983dc51fc9b5f38198c572c0eee76 Mon Sep 17 00:00:00 2001 From: Aleksandr Skoblikov Date: Wed, 21 Oct 2026 18:44:20 +0300 Subject: [PATCH 1/7] dbeaver/pro#11122 Generalize SSO login API contracts --- .../datadam/sso/api/DDSsoConstants.java | 8 +++--- .../datadam/sso/api/client/DDSsoClient.java | 10 +++---- .../datadam/sso/api/client/DDSsoService.java | 12 ++++----- ...Response.java => DDSsoConfigResponse.java} | 2 +- ...st.java => DDSsoLoginApprovalRequest.java} | 4 +-- ...e.java => DDSsoLoginApprovalResponse.java} | 2 +- .../sso/api/client/DDSsoServiceTest.java | 27 +++++++++++++++++++ 7 files changed, 47 insertions(+), 18 deletions(-) rename apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/{DDSsoDesktopConfigResponse.java => DDSsoConfigResponse.java} (91%) rename apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/{DDSsoDesktopApprovalRequest.java => DDSsoLoginApprovalRequest.java} (79%) rename apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/{DDSsoDesktopApprovalResponse.java => DDSsoLoginApprovalResponse.java} (90%) diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoConstants.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoConstants.java index 0b483a4..17247ba 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoConstants.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoConstants.java @@ -23,6 +23,7 @@ public final class DDSsoConstants { public static final String AUTHORIZE_PATH = "/authorize"; public static final String TOKEN_PATH = "/token"; public static final String HANDOFF_PATH = "/handoff"; + public static final String HANDOFF_START_PATH = "/handoff/start"; public static final String LOGOUT_PATH = "/logout"; public static final String JWKS_PATH = "/.well-known/jwks.json"; public static final String HEALTH_PATH = "/health"; @@ -42,7 +43,6 @@ public final class DDSsoConstants { public static final String ERROR_INVALID_HANDOFF = "invalid_handoff"; public static final String ERROR_INVALID_LOGOUT = "invalid_logout"; public static final String ERROR_UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type"; - public static final String ERROR_LOGIN_REQUIRED = "login_required"; public static final String ERROR_SERVER = "server_error"; public static final String ERROR_TEMPORARILY_UNAVAILABLE = "temporarily_unavailable"; @@ -92,8 +92,10 @@ public final class DDSsoConstants { public static final String TOKEN_USE_ACCESS = "access"; public static final String CLAIM_CLIENT_ID = "client_id"; public static final String TOKEN_TYPE_BEARER = "Bearer"; - public static final String DESKTOP_APPROVE_PATH = "/desktop/approve"; - public static final String DESKTOP_RESUME_PATH = "/desktop/resume"; + public static final String LOGIN_APPROVE_PATH = "/login/approve"; + public static final String LOGIN_RESUME_PATH = "/login/resume"; + public static final String APPLICATION_LOGIN_START_PATH = "/sso/login/start"; + public static final String APPLICATION_CONFIG_PATH = "/sso/config"; public static final String ACCESS_CONTEXT_PATH = "/api/internal/access-context"; public static final String HEADER_SERVICE_TOKEN = "X-DD-Service-Token"; diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java index 7526a80..e898f02 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java @@ -18,10 +18,10 @@ import com.dbeaver.datadam.sso.api.DDSsoConstants; import com.dbeaver.datadam.sso.api.exception.DDSsoClientException; -import com.dbeaver.datadam.sso.api.model.DDSsoDesktopApprovalRequest; -import com.dbeaver.datadam.sso.api.model.DDSsoDesktopApprovalResponse; import com.dbeaver.datadam.sso.api.model.DDSsoHandoffRequest; import com.dbeaver.datadam.sso.api.model.DDSsoHandoffResponse; +import com.dbeaver.datadam.sso.api.model.DDSsoLoginApprovalRequest; +import com.dbeaver.datadam.sso.api.model.DDSsoLoginApprovalResponse; import com.dbeaver.datadam.sso.api.model.DDSsoTokenRequest; import com.dbeaver.datadam.sso.api.model.DDSsoTokenResponse; import com.dbeaver.rest.client.AbstractRestClient; @@ -79,9 +79,9 @@ public DDSsoHandoffResponse createHandoff(@NotNull DDSsoHandoffRequest request) } @NotNull - public DDSsoDesktopApprovalResponse approveDesktop(@NotNull DDSsoDesktopApprovalRequest request) throws DDSsoClientException { - return postResponse(DDSsoConstants.DESKTOP_APPROVE_PATH, request, MediaType.JSON, - DDSsoDesktopApprovalResponse.class, "Invalid SSO desktop approval response"); + public DDSsoLoginApprovalResponse approveLogin(@NotNull DDSsoLoginApprovalRequest request) throws DDSsoClientException { + return postResponse(DDSsoConstants.LOGIN_APPROVE_PATH, request, MediaType.JSON, + DDSsoLoginApprovalResponse.class, "Invalid SSO login approval response"); } @NotNull diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoService.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoService.java index e0f92b9..7aa0350 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoService.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoService.java @@ -18,11 +18,11 @@ import com.dbeaver.datadam.sso.api.DDSsoConstants; import com.dbeaver.datadam.sso.api.exception.DDSsoClientException; -import com.dbeaver.datadam.sso.api.model.DDSsoDesktopApprovalRequest; -import com.dbeaver.datadam.sso.api.model.DDSsoDesktopApprovalResponse; import com.dbeaver.datadam.sso.api.model.DDSsoHandoffRequest; import com.dbeaver.datadam.sso.api.model.DDSsoHandoffResponse; import com.dbeaver.datadam.sso.api.model.DDSsoIdentity; +import com.dbeaver.datadam.sso.api.model.DDSsoLoginApprovalRequest; +import com.dbeaver.datadam.sso.api.model.DDSsoLoginApprovalResponse; import com.dbeaver.datadam.sso.api.model.DDSsoTokenRequest; import com.dbeaver.datadam.sso.api.model.DDSsoTokenResponse; import org.jkiss.code.NotNull; @@ -69,11 +69,11 @@ public String createHandoff(@NotNull DDSsoHandoffRequest request) throws DDSsoCl } @NotNull - public String approveDesktop(@NotNull DDSsoDesktopApprovalRequest request) throws DDSsoClientException { - DDSsoDesktopApprovalResponse approval = client.approveDesktop(request); + public String approveLogin(@NotNull DDSsoLoginApprovalRequest request) throws DDSsoClientException { + DDSsoLoginApprovalResponse approval = client.approveLogin(request); if (approval.completion() == null || !DDSsoConstants.BASE64URL_256_PATTERN.matcher(approval.completion()).matches()) { - throw new DDSsoClientException("Invalid SSO desktop approval response"); + throw new DDSsoClientException("Invalid SSO login approval response"); } - return endpoint(DDSsoConstants.DESKTOP_RESUME_PATH) + "?completion=" + approval.completion(); + return endpoint(DDSsoConstants.LOGIN_RESUME_PATH) + "?completion=" + approval.completion(); } } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopConfigResponse.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoConfigResponse.java similarity index 91% rename from apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopConfigResponse.java rename to apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoConfigResponse.java index 06dd740..19ded43 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopConfigResponse.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoConfigResponse.java @@ -18,5 +18,5 @@ import org.jkiss.code.NotNull; -public record DDSsoDesktopConfigResponse(@NotNull String issuer) { +public record DDSsoConfigResponse(@NotNull String issuer) { } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopApprovalRequest.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoLoginApprovalRequest.java similarity index 79% rename from apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopApprovalRequest.java rename to apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoLoginApprovalRequest.java index 5ab41c6..bc602f7 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopApprovalRequest.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoLoginApprovalRequest.java @@ -18,6 +18,6 @@ import org.jkiss.code.NotNull; -/** Account backend assertion for a browser-bound desktop authorization. */ -public record DDSsoDesktopApprovalRequest(@NotNull String requestId, @NotNull DDSsoIdentity identity) { +/** Trusted login backend assertion for a browser-bound authorization request. */ +public record DDSsoLoginApprovalRequest(@NotNull String requestId, @NotNull DDSsoIdentity identity) { } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopApprovalResponse.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoLoginApprovalResponse.java similarity index 90% rename from apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopApprovalResponse.java rename to apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoLoginApprovalResponse.java index be51ea2..91c4c32 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopApprovalResponse.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoLoginApprovalResponse.java @@ -18,5 +18,5 @@ import org.jkiss.code.NotNull; -public record DDSsoDesktopApprovalResponse(@NotNull String completion) { +public record DDSsoLoginApprovalResponse(@NotNull String completion) { } diff --git a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java index 895027d..fff7ab2 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java +++ b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java @@ -23,6 +23,7 @@ import com.dbeaver.datadam.sso.api.model.DDSsoHandoffRequest; import com.dbeaver.datadam.sso.api.model.DDSsoIdentity; import com.dbeaver.datadam.sso.api.model.DDSsoJwksResponse; +import com.dbeaver.datadam.sso.api.model.DDSsoLoginApprovalRequest; import com.dbeaver.datadam.sso.api.model.DDSsoPublicJwk; import com.dbeaver.datadam.sso.api.model.DDSsoTokenRequest; import com.dbeaver.datadam.sso.api.model.DDSsoAccessIdentity; @@ -154,6 +155,32 @@ void preservesBackchannelPathWithSharedEndpointPaths(@NotNull String path) throw Assertions.assertThat(requestPath).hasValue("/internal/handoff"); } + @Test + void approvesLoginOverAuthenticatedBackchannelAndResumesOnPublicIssuer() throws Exception { + responseJson.set(gson.toJson(Map.of("completion", "A".repeat(43)))); + var identity = new DDSsoIdentity("user-id", "user@example.com", "datadam-account", NOW); + Assertions.assertThat(service.approveLogin(new DDSsoLoginApprovalRequest("B".repeat(43), identity))) + .isEqualTo("https://sso.example.com/login/resume?completion=" + "A".repeat(43)); + Assertions.assertThat(requestMethod).hasValue("POST"); + Assertions.assertThat(requestPath).hasValue("/login/approve"); + Assertions.assertThat(credentials).hasValue("Basic " + Base64.getEncoder() + .encodeToString("account:test-secret".getBytes(StandardCharsets.UTF_8))); + var body = JsonParser.parseString(posted.get()).getAsJsonObject(); + Assertions.assertThat(body.get("requestId").getAsString()).isEqualTo("B".repeat(43)); + Assertions.assertThat(body.getAsJsonObject("identity").get("subject").getAsString()).isEqualTo(identity.subject()); + Assertions.assertThat(body.getAsJsonObject("identity").get("authTime").getAsString()).isEqualTo(NOW.toString()); + Assertions.assertThat(jwksRequests).hasValue(0); + } + + @ParameterizedTest + @ValueSource(strings = {"{}", "{\"completion\":null}", "{\"completion\":\"\"}", "{\"completion\":\"unsafe&state=other\"}"}) + void rejectsInvalidLoginCompletionsBeforeBuildingRedirect(String json) { + responseJson.set(json); + Assertions.assertThatThrownBy(() -> service.approveLogin(new DDSsoLoginApprovalRequest("B".repeat(43), + new DDSsoIdentity("user-id", "user@example.com", "datadam-account", NOW)))) + .isInstanceOf(DDSsoClientException.class); + } + @Test void acceptsOnlyDesktopAccessTokensWithApiAudienceAndAccessPurpose() throws Exception { var claims = claims(); From f381fc2b778ae23dbd2407f25f1aa0f0071d8a42 Mon Sep 17 00:00:00 2001 From: Aleksandr Skoblikov Date: Thu, 22 Oct 2026 16:08:26 +0300 Subject: [PATCH 2/7] dbeaver/pro#11122 Add scoped access token contracts and verification --- README.md | 30 +++++-- .../datadam/sso/api/DDSsoConstants.java | 16 +++- .../dbeaver/datadam/sso/api/DDSsoScopes.java | 55 ++++++++++++ .../sso/api/client/DDSsoAccountClient.java | 11 ++- .../datadam/sso/api/client/DDSsoClient.java | 11 ++- .../sso/api/client/DDSsoClientConfig.java | 4 +- .../datadam/sso/api/client/DDSsoService.java | 3 +- .../sso/api/client/DDSsoTokenVerifier.java | 15 ++-- .../DDSsoInsufficientScopeException.java} | 19 ++-- .../sso/api/model/DDSsoAccessIdentity.java | 7 +- .../sso/api/model/DDSsoAuthorizeRequest.java | 6 +- .../sso/api/model/DDSsoTokenRequest.java | 4 +- .../sso/api/model/DDSsoTokenResponse.java | 11 ++- .../datadam/sso/api/DDSsoScopesTest.java | 43 +++++++++ .../sso/api/client/DDSsoServiceTest.java | 87 ++++++++++++++----- 15 files changed, 264 insertions(+), 58 deletions(-) create mode 100644 apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoScopes.java rename apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/{model/DDSsoDesktopTokenResponse.java => exception/DDSsoInsufficientScopeException.java} (51%) create mode 100644 apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/DDSsoScopesTest.java diff --git a/README.md b/README.md index d3ecb3e..216dcc1 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,13 @@ Used by DBeaver, dbvr, CloudBeaver and by DataDam itself `apis/com.dbeaver.datadam.sso.api` exports the shared SSO contracts in `com.dbeaver.datadam.sso.api.model`: -* `DDSsoAuthorizeRequest`: client, redirect URI, state and PKCE challenge parameters. +* `DDSsoAuthorizeRequest`: client, redirect URI, state, PKCE challenge, optional target resource URI and requested scopes. * `DDSsoIdentity`: source-scoped subject, email, identity source and original authentication time. -* `DDSsoTokenRequest`: authorization code, PKCE verifier, client and redirect URI. -* `DDSsoTokenResponse`: signed identity token (`id_token`) and lifetime in seconds (`expires_in`). +* `DDSsoTokenRequest`: authorization code, PKCE verifier, client, redirect URI and optional resource URI. +* `DDSsoTokenResponse`: `access_token`, `token_type`, access lifetime (`expires_in`), granted `scope`, + and an optional internal `id_token` for clients configured to receive identity assertions. +* `DDSsoAccessIdentity`: verified subject, source, client ID, audience and granted scopes. +* `DDSsoConfigResponse`: SSO issuer, obtained from the application's `/sso/config`. * `DDSsoHandoffRequest`: confirmed identity, original authorization request and browser-binding `flow_id`. * `DDSsoHandoffResponse`: opaque one-time `handoff` and lifetime in seconds (`expires_in`). @@ -29,14 +32,27 @@ Token response JSON field names are declared with Gson `@SerializedName` annotat Handoff creation uses `POST /handoff` with HTTP Basic backend authentication and a JSON body. The nested authorization fields use their Java camelCase names; `flow_id` and `expires_in` use explicit Gson mappings. The identity's `authTime` uses ISO-8601 (`GsonUtils.InstantIsoAdapter`). Browser completion uses -`GET /handoff?handoff=...` and requires the proof cookie set by the initial SSO authorization request. +`GET /handoff?handoff=...` and requires the proof cookie set by `/handoff/start`. Token exchange uses `POST /token` with `application/x-www-form-urlencoded` fields `code`, `code_verifier`, -`client_id` and `redirect_uri`. If supplied, `grant_type` must equal `authorization_code`. Clients with a +`client_id`, `redirect_uri`, optional `resource` and mandatory `grant_type=authorization_code`. Clients with a configured secret authenticate through HTTP Basic; public clients use code and PKCE without credentials. -The response is `DDSsoTokenResponse` with an application-specific identity JWT and `expires_in` in seconds. +Every successful exchange returns an access JWT. Ordinary DataDam APIs use the fixed `DDSsoConstants.API_AUDIENCE`; +MCP access uses its authorized resource URI as `aud`. +Account/Admin additionally use their +internal identity JWT to establish local browser sessions; this is not an OpenID Connect contract. +`DDSsoTokenVerifier.verifyAccessToken` requires the expected audience explicitly; API consumers must +check their required scopes and current business permissions. Identity tokens are never API credentials. Failed exchanges return a JSON `error` (`invalid_request`, `unsupported_grant_type`, `invalid_client`, -`invalid_grant` or `server_error`). +`invalid_grant`, `invalid_target` or `server_error`). + +Authorization uses `response_type=code`, space-separated `scope`, and S256 PKCE. +Ordinary API access uses the client's top-level `scopes` without any resource configuration or request parameter. +MCP access uses `allowed-resources` with its own scope sets. During code exchange a supplied resource must match the saved grant. +MCP requires `resource` in both requests, configured with `require-resource: true` on its client resource entries. +SSO publishes RFC 8414 metadata at `/.well-known/oauth-authorization-server` (with the issuer path appended +to that suffix when applicable). Browser callbacks contain `iss` per RFC 9207: clients compare it exactly +to the issuer selected before authorization. Scopes follow RFC 6749 syntax, parsed by `DDSsoScopes`. ## License Manager API diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoConstants.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoConstants.java index 17247ba..972e61d 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoConstants.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoConstants.java @@ -26,6 +26,7 @@ public final class DDSsoConstants { public static final String HANDOFF_START_PATH = "/handoff/start"; public static final String LOGOUT_PATH = "/logout"; public static final String JWKS_PATH = "/.well-known/jwks.json"; + public static final String METADATA_PATH = "/.well-known/oauth-authorization-server"; public static final String HEALTH_PATH = "/health"; public static final String PARAM_FLOW_ID = "flow_id"; @@ -33,6 +34,10 @@ public final class DDSsoConstants { public static final String PARAM_LOGOUT = "logout"; public static final String PARAM_LOGOUT_CHALLENGE = "logout_challenge"; public static final String PARAM_PROMPT = "prompt"; + public static final String PARAM_RESOURCE = "resource"; + public static final String PARAM_SCOPE = "scope"; + public static final String PARAM_RESPONSE_TYPE = "response_type"; + public static final String RESPONSE_TYPE_CODE = "code"; public static final String PARAM_ERROR_URI = "error_uri"; public static final String RESPONSE_EXPIRES_IN = "expires_in"; public static final String PROMPT_LOGIN = "login"; @@ -40,6 +45,9 @@ public final class DDSsoConstants { public static final String ERROR_INVALID_REQUEST = "invalid_request"; public static final String ERROR_INVALID_CLIENT = "invalid_client"; public static final String ERROR_INVALID_GRANT = "invalid_grant"; + public static final String ERROR_INVALID_TARGET = "invalid_target"; + public static final String ERROR_INVALID_SCOPE = "invalid_scope"; + public static final String ERROR_UNSUPPORTED_RESPONSE_TYPE = "unsupported_response_type"; public static final String ERROR_INVALID_HANDOFF = "invalid_handoff"; public static final String ERROR_INVALID_LOGOUT = "invalid_logout"; public static final String ERROR_UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type"; @@ -88,7 +96,13 @@ public final class DDSsoConstants { public static final String REFERRER_POLICY_NO_REFERRER = "no-referrer"; public static final String DESKTOP_CLIENT_ID = "dbeaver-desktop"; public static final String DESKTOP_CALLBACK_TEMPLATE = "http://127.0.0.1/callback"; - public static final String ACCESS_AUDIENCE = "datadam-api"; + /** Fixed audience for ordinary DataDam API access tokens; not a client resource setting or an endpoint URL. */ + public static final String API_AUDIENCE = "https://datadam.cloud/api"; + public static final String SCOPE_ACCOUNT_ACCESS = "account:access"; + public static final String SCOPE_DBEAVER_STORAGE_ACCESS = "dbeaver-storage:access"; + public static final String SCOPE_ADMIN_ACCESS = "admin:access"; + public static final String SCOPE_MCP_CONNECT = "mcp:connect"; + public static final String SCOPE_MCP_INVOKE = "mcp:invoke"; public static final String TOKEN_USE_ACCESS = "access"; public static final String CLAIM_CLIENT_ID = "client_id"; public static final String TOKEN_TYPE_BEARER = "Bearer"; diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoScopes.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoScopes.java new file mode 100644 index 0000000..55038ab --- /dev/null +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoScopes.java @@ -0,0 +1,55 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.sso.api; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Collection; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; + +/** OAuth scope-token syntax (RFC 6749 section 3.3) and stable wire representation. */ +public final class DDSsoScopes { + private static final Pattern TOKEN = Pattern.compile("[\\x21\\x23-\\x5B\\x5D-\\x7E]+"); + + private DDSsoScopes() { + } + + @NotNull + public static Set parse(@Nullable String scope) { + if (scope == null || scope.isEmpty() || scope.length() > 4096) { + throw new IllegalArgumentException("OAuth scopes are required"); + } + Set scopes = new TreeSet<>(); + for (String value : scope.split(" ", -1)) { + if (!TOKEN.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid OAuth scope"); + } + scopes.add(value); + } + return Set.copyOf(scopes); + } + + @NotNull + public static String format(@NotNull Collection scopes) { + String value = String.join(" ", new TreeSet<>(scopes)); + parse(value); + return value; + } +} diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoAccountClient.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoAccountClient.java index c36d5b5..e56b896 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoAccountClient.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoAccountClient.java @@ -18,6 +18,7 @@ import com.dbeaver.datadam.sso.api.DDSsoConstants; import com.dbeaver.datadam.sso.api.exception.DDSsoClientException; +import com.dbeaver.datadam.sso.api.exception.DDSsoInsufficientScopeException; import com.dbeaver.datadam.sso.api.model.DDSsoAccessContextResponse; import com.dbeaver.rest.client.AbstractRestClient; import com.dbeaver.rest.client.MediaType; @@ -72,6 +73,8 @@ public DDSsoAccessContextResponse exchange(@NotNull String accessToken) throws D UUID.fromString(context.userId()); UUID.fromString(context.accountId()); return context; + } catch (InsufficientScopeResponse e) { + throw new DDSsoInsufficientScopeException(); } catch (DBException | IllegalArgumentException e) { throw new DDSsoClientException("Account service is unavailable"); } @@ -80,6 +83,12 @@ public DDSsoAccessContextResponse exchange(@NotNull String accessToken) throws D @NotNull @Override protected DBException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { - return new DBException("Account access context unavailable"); + return code == 403 ? new InsufficientScopeResponse() : new DBException("Account access context unavailable"); + } + + private static final class InsufficientScopeResponse extends DBException { + private InsufficientScopeResponse() { + super("Insufficient storage access scope"); + } } } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java index e898f02..036acc1 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java @@ -31,6 +31,7 @@ import org.jkiss.utils.CommonUtils; import org.jkiss.utils.GsonUtils; import org.jkiss.utils.HttpConstants; +import org.jkiss.utils.oauth.OAuthConstants; import java.io.IOException; import java.lang.reflect.Type; @@ -40,6 +41,7 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Base64; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -68,7 +70,14 @@ private static URI endpoint(@NotNull String base, @NotNull String path) { @NotNull public DDSsoTokenResponse exchange(@NotNull DDSsoTokenRequest request) throws DDSsoClientException { - return postResponse(DDSsoConstants.TOKEN_PATH, request, MediaType.FORM_URLENCODED, + Map parameters = new HashMap<>(Map.of( + OAuthConstants.PARAM_GRANT_TYPE, OAuthConstants.GRANT_TYPE_AUTH_CODE, + OAuthConstants.PARAM_CODE, request.code(), OAuthConstants.PARAM_CODE_VERIFIER, request.codeVerifier(), + OAuthConstants.AUTH_PROP_CLIENT_ID, request.clientId(), OAuthConstants.PARAM_REDIRECT_URI, request.redirectUri())); + if (request.resource() != null) { + parameters.put(DDSsoConstants.PARAM_RESOURCE, request.resource()); + } + return postResponse(DDSsoConstants.TOKEN_PATH, parameters, MediaType.FORM_URLENCODED, DDSsoTokenResponse.class, "Invalid SSO token response"); } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClientConfig.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClientConfig.java index 3337ead..42fdfb9 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClientConfig.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClientConfig.java @@ -29,7 +29,9 @@ public record DDSsoClientConfig( @NotNull String clientSecret, @NotNull String redirectUri, @NotNull String identitySource, - @NotNull Duration jwksCacheTtl + @NotNull Duration jwksCacheTtl, + @Nullable String resource, + @NotNull String scope ) { @Override public String toString() { diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoService.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoService.java index 7aa0350..ba007a1 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoService.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoService.java @@ -51,7 +51,8 @@ public URI endpoint(@NotNull String path) { @NotNull public DDSsoIdentity exchange(@NotNull String code, @NotNull String codeVerifier) throws DDSsoClientException { - DDSsoTokenResponse token = client.exchange(new DDSsoTokenRequest(code, codeVerifier, properties.clientId(), properties.redirectUri())); + DDSsoTokenResponse token = client.exchange(new DDSsoTokenRequest(code, codeVerifier, properties.clientId(), + properties.redirectUri(), properties.resource())); if (token.idToken() == null || token.idToken().length() > DDSsoTokenVerifier.MAX_TOKEN_LENGTH || token.expiresIn() <= 0) { throw new DDSsoClientException("Invalid SSO token response"); } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoTokenVerifier.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoTokenVerifier.java index b06b064..a8b0120 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoTokenVerifier.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoTokenVerifier.java @@ -17,6 +17,7 @@ package com.dbeaver.datadam.sso.api.client; import com.dbeaver.datadam.sso.api.DDSsoConstants; +import com.dbeaver.datadam.sso.api.DDSsoScopes; import com.dbeaver.datadam.sso.api.exception.DDSsoClientException; import com.dbeaver.datadam.sso.api.model.DDSsoAccessIdentity; import com.dbeaver.datadam.sso.api.model.DDSsoIdentity; @@ -86,20 +87,20 @@ public DDSsoIdentity verifyIdentity(@NotNull String encoded) throws DDSsoClientE } @NotNull - public DDSsoAccessIdentity verifyAccessToken(@NotNull String encoded) throws DDSsoClientException { + public DDSsoAccessIdentity verifyAccessToken(@NotNull String encoded, @NotNull String expectedAudience) throws DDSsoClientException { try { JsonObject claims = verifiedClaims(encoded); - if (!matchesAudience(claims.get(DDSsoConstants.CLAIM_AUDIENCE), DDSsoConstants.ACCESS_AUDIENCE) - || !DDSsoConstants.TOKEN_USE_ACCESS.equals(DDSsoJson.text(claims.get(DDSsoConstants.CLAIM_TOKEN_USE))) - || !properties.identitySource().equals(DDSsoJson.text(claims.get(DDSsoConstants.CLAIM_SOURCE))) - || !DDSsoConstants.DESKTOP_CLIENT_ID.equals(DDSsoJson.text(claims.get(DDSsoConstants.CLAIM_CLIENT_ID)))) { + if (expectedAudience.isBlank() || !matchesAudience(claims.get(DDSsoConstants.CLAIM_AUDIENCE), expectedAudience) + || !DDSsoConstants.TOKEN_USE_ACCESS.equals(DDSsoJson.text(claims.get(DDSsoConstants.CLAIM_TOKEN_USE)))) { throw new IllegalArgumentException(); } return new DDSsoAccessIdentity( DDSsoJson.text(claims.get(DDSsoConstants.CLAIM_SUBJECT)), DDSsoJson.text(claims.get(DDSsoConstants.CLAIM_EMAIL)), - properties.identitySource(), DDSsoConstants.DESKTOP_CLIENT_ID, - date(claims.get(DDSsoConstants.CLAIM_AUTH_TIME))); + DDSsoJson.text(claims.get(DDSsoConstants.CLAIM_SOURCE)), DDSsoJson.text(claims.get(DDSsoConstants.CLAIM_CLIENT_ID)), + date(claims.get(DDSsoConstants.CLAIM_AUTH_TIME)), expectedAudience, + DDSsoScopes.parse(DDSsoJson.text(claims.get(DDSsoConstants.PARAM_SCOPE))) + ); } catch (Exception e) { throw new DDSsoClientException("Invalid SSO access token"); } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopTokenResponse.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/exception/DDSsoInsufficientScopeException.java similarity index 51% rename from apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopTokenResponse.java rename to apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/exception/DDSsoInsufficientScopeException.java index 4086950..6e59906 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopTokenResponse.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/exception/DDSsoInsufficientScopeException.java @@ -14,18 +14,11 @@ * is strictly forbidden unless prior written permission is obtained * from DBeaver Corp. */ -package com.dbeaver.datadam.sso.api.model; +package com.dbeaver.datadam.sso.api.exception; -import com.dbeaver.datadam.sso.api.DDSsoConstants; -import com.google.gson.annotations.SerializedName; -import org.jkiss.code.NotNull; -import org.jkiss.utils.oauth.OAuthConstants; - -/** Tokens issued solely to the public desktop client. */ -public record DDSsoDesktopTokenResponse( - @SerializedName("access_token") @NotNull String accessToken, - @SerializedName("token_type") @NotNull String tokenType, - @SerializedName(DDSsoConstants.RESPONSE_EXPIRES_IN) long expiresIn, - @SerializedName(OAuthConstants.RESULT_PROP_TOKEN_ID) @NotNull String idToken -) { +/** A valid token cannot be used for the requested storage access context. */ +public class DDSsoInsufficientScopeException extends DDSsoClientException { + public DDSsoInsufficientScopeException() { + super("Insufficient storage access scope"); + } } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoAccessIdentity.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoAccessIdentity.java index fee9477..eb746cc 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoAccessIdentity.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoAccessIdentity.java @@ -19,10 +19,15 @@ import org.jkiss.code.NotNull; import java.time.Instant; +import java.util.Set; /** Validated access claims; authorization is resolved from current database state by each API. */ public record DDSsoAccessIdentity( @NotNull String subject, @NotNull String email, @NotNull String source, - @NotNull String clientId, @NotNull Instant authTime + @NotNull String clientId, @NotNull Instant authTime, + @NotNull String audience, @NotNull Set scopes ) { + public DDSsoAccessIdentity { + scopes = Set.copyOf(scopes); + } } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoAuthorizeRequest.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoAuthorizeRequest.java index e3d36d6..2a7419e 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoAuthorizeRequest.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoAuthorizeRequest.java @@ -17,16 +17,20 @@ package com.dbeaver.datadam.sso.api.model; import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; /** * Parameters of an authorization request. Only the S256 PKCE challenge method is supported. * HTTP controllers must explicitly bind the corresponding snake_case query parameters. + * Ordinary DataDam access uses scopes only. Resource identifies an explicitly configured target such as MCP. */ public record DDSsoAuthorizeRequest( @NotNull String clientId, @NotNull String redirectUri, @NotNull String state, @NotNull String codeChallenge, - @NotNull String codeChallengeMethod + @NotNull String codeChallengeMethod, + @Nullable String resource, + @NotNull String scope ) { } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoTokenRequest.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoTokenRequest.java index 6f94b81..0b10916 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoTokenRequest.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoTokenRequest.java @@ -18,6 +18,7 @@ import com.google.gson.annotations.SerializedName; import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; import org.jkiss.utils.oauth.OAuthConstants; /** @@ -27,6 +28,7 @@ public record DDSsoTokenRequest( @NotNull String code, @SerializedName(OAuthConstants.PARAM_CODE_VERIFIER) @NotNull String codeVerifier, @SerializedName(OAuthConstants.AUTH_PROP_CLIENT_ID) @NotNull String clientId, - @SerializedName(OAuthConstants.PARAM_REDIRECT_URI) @NotNull String redirectUri + @SerializedName(OAuthConstants.PARAM_REDIRECT_URI) @NotNull String redirectUri, + @Nullable String resource ) { } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoTokenResponse.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoTokenResponse.java index 43cf4d4..4ca81e1 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoTokenResponse.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoTokenResponse.java @@ -19,13 +19,18 @@ import com.dbeaver.datadam.sso.api.DDSsoConstants; import com.google.gson.annotations.SerializedName; import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; import org.jkiss.utils.oauth.OAuthConstants; /** - * Identity JWT for the requesting application. Expiration is expressed in seconds. + * OAuth access token and an optional internal identity JWT. This is not an OIDC contract. + * expires_in is the access token lifetime in seconds; identity JWTs carry their own expiry. */ public record DDSsoTokenResponse( - @SerializedName(OAuthConstants.RESULT_PROP_TOKEN_ID) @NotNull String idToken, - @SerializedName(DDSsoConstants.RESPONSE_EXPIRES_IN) long expiresIn + @SerializedName("access_token") @NotNull String accessToken, + @SerializedName("token_type") @NotNull String tokenType, + @SerializedName(DDSsoConstants.RESPONSE_EXPIRES_IN) long expiresIn, + @NotNull String scope, + @SerializedName(OAuthConstants.RESULT_PROP_TOKEN_ID) @Nullable String idToken ) { } diff --git a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/DDSsoScopesTest.java b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/DDSsoScopesTest.java new file mode 100644 index 0000000..229b2b1 --- /dev/null +++ b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/DDSsoScopesTest.java @@ -0,0 +1,43 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package com.dbeaver.datadam.sso.api; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Set; + +class DDSsoScopesTest { + @Test + void scopesAreCaseSensitiveAndHaveAStableRepresentation() { + var scopes = DDSsoScopes.parse("dbeaver-storage:access account:access account:access"); + Assertions.assertThat(scopes).isEqualTo(Set.of("dbeaver-storage:access", "account:access")); + Assertions.assertThat(DDSsoScopes.format(scopes)).isEqualTo("account:access dbeaver-storage:access"); + Assertions.assertThat(DDSsoScopes.parse("Account:access")).doesNotContain("account:access"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", " account:access", "account:access ", "account:access dbeaver-storage:access", + "account:access\tdbeaver-storage:access", "account:access\n", "a\"b", "a\\b", "права"}) + void rejectsInvalidWireScopes(String scope) { + Assertions.assertThatThrownBy(() -> DDSsoScopes.parse(scope)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java index fff7ab2..b797253 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java +++ b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java @@ -114,7 +114,7 @@ void setUp() throws Exception { server.start(); DDSsoClientConfig config = new DDSsoClientConfig("https://sso.example.com", "http://127.0.0.1:" + server.getAddress().getPort(), "account", "test-secret", - "https://account.example.com/sso/callback", "datadam-account", Duration.ofMinutes(5)); + "https://account.example.com/sso/callback", "datadam-account", Duration.ofMinutes(5), null, "account:access"); Clock clock = clock(ZoneOffset.UTC); http = new DDSsoClient(config); keyCache = new DDSsoJwksCache(http, config.jwksCacheTtl(), clock); @@ -136,7 +136,7 @@ void stop() { }) void joinsSharedPathsWithoutLosingIssuerPathOrDuplicatingSlashes(@NotNull String issuer, @NotNull String expected) { var configured = new DDSsoClient(new DDSsoClientConfig(issuer, "", "account", "test-secret", - "https://account.example.com/sso/callback", "datadam-account", Duration.ofMinutes(5))); + "https://account.example.com/sso/callback", "datadam-account", Duration.ofMinutes(5), null, "account:access")); Assertions.assertThat(configured.endpoint(DDSsoConstants.AUTHORIZE_PATH).toString()).isEqualTo(expected); Assertions.assertThat(configured.endpoint("authorize").toString()).isEqualTo(expected); } @@ -146,11 +146,12 @@ void joinsSharedPathsWithoutLosingIssuerPathOrDuplicatingSlashes(@NotNull String void preservesBackchannelPathWithSharedEndpointPaths(@NotNull String path) throws Exception { var config = new DDSsoClientConfig("https://sso.example.com/issuer/", "http://127.0.0.1:" + server.getAddress().getPort() + path, "account", "test-secret", - "https://account.example.com/sso/callback", "datadam-account", Duration.ofMinutes(5)); + "https://account.example.com/sso/callback", "datadam-account", Duration.ofMinutes(5), null, "account:access"); var configured = new DDSsoService(config, new DDSsoClient(config), verifier); responseJson.set(gson.toJson(Map.of("handoff", "A".repeat(43), "expires_in", 60))); var request = new DDSsoHandoffRequest(new DDSsoIdentity("user-id", "user@example.com", "datadam-account", NOW), - new DDSsoAuthorizeRequest("account", "https://account.example.com/sso/callback", "state", "A".repeat(43), "S256"), "B".repeat(43)); + new DDSsoAuthorizeRequest("account", "https://account.example.com/sso/callback", "state", "A".repeat(43), "S256", + null, "account:access"), "B".repeat(43)); Assertions.assertThat(configured.createHandoff(request)).isEqualTo("https://sso.example.com/issuer/handoff?handoff=" + "A".repeat(43)); Assertions.assertThat(requestPath).hasValue("/internal/handoff"); } @@ -181,24 +182,51 @@ void rejectsInvalidLoginCompletionsBeforeBuildingRedirect(String json) { .isInstanceOf(DDSsoClientException.class); } - @Test - void acceptsOnlyDesktopAccessTokensWithApiAudienceAndAccessPurpose() throws Exception { + @ParameterizedTest + @ValueSource(strings = {"dbeaver-desktop", "account", "external-client"}) + void acceptsAccessTokensForAnyClientButOnlyTheExpectedAudienceAndPurpose(String clientId) throws Exception { var claims = claims(); - claims.put("aud", DDSsoConstants.ACCESS_AUDIENCE); + claims.put("aud", DDSsoConstants.API_AUDIENCE); claims.put("token_use", DDSsoConstants.TOKEN_USE_ACCESS); - claims.put("client_id", DDSsoConstants.DESKTOP_CLIENT_ID); + claims.put("client_id", clientId); + claims.put("scope", "account:access dbeaver-storage:access"); String access = signed(claims, header("key1")); - Assertions.assertThat(verifier.verifyAccessToken(access)).isEqualTo(new DDSsoAccessIdentity( - "user-id", "user@example.com", "datadam-account", DDSsoConstants.DESKTOP_CLIENT_ID, NOW.minusSeconds(60))); + Assertions.assertThat(verifier.verifyAccessToken(access, DDSsoConstants.API_AUDIENCE)).isEqualTo(new DDSsoAccessIdentity( + "user-id", "user@example.com", "datadam-account", clientId, NOW.minusSeconds(60), DDSsoConstants.API_AUDIENCE, + java.util.Set.of("account:access", "dbeaver-storage:access"))); + Assertions.assertThatThrownBy(() -> verifier.verifyAccessToken(access, "https://mcp.example.com")) + .isInstanceOf(DDSsoClientException.class); Assertions.assertThatThrownBy(() -> verifier.verifyIdentity(access)).isInstanceOf(DDSsoClientException.class); - for (String name : List.of("aud", "token_use", "client_id")) { + for (String name : List.of("aud", "token_use", "client_id", "scope")) { var modified = new HashMap<>(claims); - modified.put(name, "unexpected"); + modified.put(name, ""); String invalid = signed(modified, header("key1")); - Assertions.assertThatThrownBy(() -> verifier.verifyAccessToken(invalid)).isInstanceOf(DDSsoClientException.class); + Assertions.assertThatThrownBy(() -> verifier.verifyAccessToken(invalid, DDSsoConstants.API_AUDIENCE)) + .isInstanceOf(DDSsoClientException.class); } - Assertions.assertThatThrownBy(() -> verifier.verifyAccessToken(signed(claims(), header("key1")))) + Assertions.assertThatThrownBy(() -> verifier.verifyAccessToken(signed(claims(), header("key1")), DDSsoConstants.API_AUDIENCE)) .isInstanceOf(DDSsoClientException.class); + var mcpClaims = new HashMap<>(claims); + mcpClaims.put("aud", "https://mcp.example.com"); + mcpClaims.put("scope", "mcp:invoke"); + String mcpToken = signed(mcpClaims, header("key1")); + Assertions.assertThatThrownBy(() -> verifier.verifyAccessToken(mcpToken, DDSsoConstants.API_AUDIENCE)) + .isInstanceOf(DDSsoClientException.class); + Assertions.assertThat(verifier.verifyAccessToken(mcpToken, "https://mcp.example.com").scopes()).containsExactly("mcp:invoke"); + } + + @Test + void accessIdentityPreservesExternalSourceForResourceSpecificUserResolution() throws Exception { + var claims = claims(); + claims.put("aud", DDSsoConstants.API_AUDIENCE); + claims.put("token_use", "access"); + claims.put("client_id", "external-client"); + claims.put("scope", "account:access"); + claims.put("source", "external-directory"); + claims.put("sub", "external-user-42"); + var identity = verifier.verifyAccessToken(signed(claims, header("key1")), DDSsoConstants.API_AUDIENCE); + Assertions.assertThat(identity.source()).isEqualTo("external-directory"); + Assertions.assertThat(identity.subject()).isEqualTo("external-user-42"); } @Test @@ -211,6 +239,14 @@ void validatesTokenAndCachesTrustedJwks() throws Exception { Assertions.assertThat(jwksMethod).hasValue("GET"); } + @Test + void tokenTransportOmitsAbsentResource() throws Exception { + responseJson.set("{\"access_token\":\"test-token\",\"token_type\":\"Bearer\",\"expires_in\":60,\"scope\":\"account:access\"}"); + http.exchange(new com.dbeaver.datadam.sso.api.model.DDSsoTokenRequest( + "test-code", "A".repeat(43), "account", "https://account.example.com/sso/callback", null)); + Assertions.assertThat(posted.get()).contains("grant_type=authorization_code", "code=test-code").doesNotContain("resource"); + } + @Test void verifiesOriginalEncodedSegmentsWithWhitespaceUnicodeAndArrayAudience() throws Exception { Map claims = claims(); @@ -345,7 +381,7 @@ void credentialBearingClientsDoNotFollowRedirects() throws Exception { responseStatus.set(307); redirect.set("http://127.0.0.1:" + destination.getAddress().getPort()); Assertions.assertThatThrownBy(() -> http.exchange(new DDSsoTokenRequest( - "code", "verifier", "account", "https://account.example.com/sso/callback"))) + "code", "verifier", "account", "https://account.example.com/sso/callback", null))) .isInstanceOf(DDSsoClientException.class); var account = new DDSsoAccountClient("http://127.0.0.1:" + server.getAddress().getPort(), "s".repeat(32)); Assertions.assertThatThrownBy(() -> account.exchange("test-access-token")) @@ -356,12 +392,21 @@ void credentialBearingClientsDoNotFollowRedirects() throws Exception { } } + @Test + void accountBackchannelPreservesInsufficientScopeResponse() { + responseJson.set("{\"error\":\"insufficient_scope\"}"); + responseStatus.set(403); + var account = new DDSsoAccountClient("http://127.0.0.1:" + server.getAddress().getPort(), "s".repeat(32)); + Assertions.assertThatThrownBy(() -> account.exchange("header.payload.signature")) + .isInstanceOf(com.dbeaver.datadam.sso.api.exception.DDSsoInsufficientScopeException.class); + } + @Test void httpClientDeserializesTheEntireResponse() throws Exception { String token = "t".repeat(1_048_576); responseJson.set(gson.toJson(Map.of("id_token", token, "expires_in", 300))); var response = http.exchange(new DDSsoTokenRequest( - "code", "verifier", "account", "https://account.example.com/sso/callback")); + "code", "verifier", "account", "https://account.example.com/sso/callback", null)); Assertions.assertThat(response.idToken()).isEqualTo(token); Assertions.assertThat(jwksRequests).hasValue(0); } @@ -369,7 +414,7 @@ void httpClientDeserializesTheEntireResponse() throws Exception { @Test void httpClientReturnsUnverifiedTokenWithoutFetchingSigningKeys() throws Exception { responseJson.set(gson.toJson(Map.of("id_token", "opaque-unverified-token", "expires_in", 300))); - var response = http.exchange(new DDSsoTokenRequest("code", "verifier", "account", "https://account.example.com/sso/callback")); + var response = http.exchange(new DDSsoTokenRequest("code", "verifier", "account", "https://account.example.com/sso/callback", null)); Assertions.assertThat(response.idToken()).isEqualTo("opaque-unverified-token"); Assertions.assertThat(response.expiresIn()).isEqualTo(300); Assertions.assertThat(jwksRequests).hasValue(0); @@ -400,9 +445,10 @@ void exchangesCodeAndVerifierOnlyInAuthenticatedFormBody() throws Exception { } Assertions.assertThat(form).containsEntry("code", "code+/%").containsEntry("code_verifier", "verifier") .containsEntry("client_id", "account").containsEntry("redirect_uri", "https://account.example.com/sso/callback"); - Assertions.assertThat(form).containsOnlyKeys("code", "code_verifier", "client_id", "redirect_uri"); + Assertions.assertThat(form).containsOnlyKeys("grant_type", "code", "code_verifier", "client_id", "redirect_uri") + .containsEntry("grant_type", "authorization_code").doesNotContainKey("resource"); Assertions.assertThat(gson.fromJson(gson.toJson(form), DDSsoTokenRequest.class)).isEqualTo(new DDSsoTokenRequest( - "code+/%", "verifier", "account", "https://account.example.com/sso/callback" + "code+/%", "verifier", "account", "https://account.example.com/sso/callback", null )); } @@ -424,7 +470,8 @@ void preservesExternalSourceAndSubjectAfterVerifyingIdentityToken() throws Excep void postsHandoffAndUsesPublicIssuerForBrowserRedirect() throws Exception { responseJson.set(gson.toJson(Map.of("handoff", "A".repeat(43), "expires_in", 60))); var request = new DDSsoHandoffRequest(new DDSsoIdentity("user-id", "user@example.com", "datadam-account", NOW), - new DDSsoAuthorizeRequest("account", "https://account.example.com/sso/callback", "state", "A".repeat(43), "S256"), "B".repeat(43)); + new DDSsoAuthorizeRequest("account", "https://account.example.com/sso/callback", "state", "A".repeat(43), "S256", + null, "account:access"), "B".repeat(43)); Assertions.assertThat(service.createHandoff(request)).isEqualTo("https://sso.example.com/handoff?handoff=" + "A".repeat(43)); Assertions.assertThat(requestMethod).hasValue("POST"); Assertions.assertThat(requestPath).hasValue("/handoff"); From f3b338e987ce020d596d71be3fa415dd1eb4cfde Mon Sep 17 00:00:00 2001 From: Aleksandr Skoblikov Date: Thu, 22 Oct 2026 19:47:04 +0300 Subject: [PATCH 3/7] dbeaver/pro#11122 Define permission group scopes for API clients --- README.md | 7 ++++-- .../datadam/sso/api/DDSsoConstants.java | 6 ++--- .../sso/api/client/DDSsoAccountClient.java | 11 +-------- .../DDSsoInsufficientScopeException.java | 24 ------------------- .../sso/api/client/DDSsoServiceTest.java | 6 ++--- 5 files changed, 12 insertions(+), 42 deletions(-) delete mode 100644 apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/exception/DDSsoInsufficientScopeException.java diff --git a/README.md b/README.md index 216dcc1..e297a1a 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,11 @@ Every successful exchange returns an access JWT. Ordinary DataDam APIs use the f MCP access uses its authorized resource URI as `aud`. Account/Admin additionally use their internal identity JWT to establish local browser sessions; this is not an OpenID Connect contract. -`DDSsoTokenVerifier.verifyAccessToken` requires the expected audience explicitly; API consumers must -check their required scopes and current business permissions. Identity tokens are never API credentials. +`DDSsoTokenVerifier.verifyAccessToken` requires the expected audience explicitly. API authentication intersects +current database permissions with the permissions mapped from the token's individual or full-access scopes. +Controllers use ordinary permission checks. Identity tokens are never API credentials. +First-party clients request `account:full_access`, `dbeaver-storage:full_access` or `admin:full_access` as appropriate; +these scopes limit delegated access and never grant rights missing from the user's current database roles. Failed exchanges return a JSON `error` (`invalid_request`, `unsupported_grant_type`, `invalid_client`, `invalid_grant`, `invalid_target` or `server_error`). diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoConstants.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoConstants.java index 972e61d..8cfa908 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoConstants.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/DDSsoConstants.java @@ -98,9 +98,9 @@ public final class DDSsoConstants { public static final String DESKTOP_CALLBACK_TEMPLATE = "http://127.0.0.1/callback"; /** Fixed audience for ordinary DataDam API access tokens; not a client resource setting or an endpoint URL. */ public static final String API_AUDIENCE = "https://datadam.cloud/api"; - public static final String SCOPE_ACCOUNT_ACCESS = "account:access"; - public static final String SCOPE_DBEAVER_STORAGE_ACCESS = "dbeaver-storage:access"; - public static final String SCOPE_ADMIN_ACCESS = "admin:access"; + public static final String SCOPE_ACCOUNT_FULL_ACCESS = "account:full_access"; + public static final String SCOPE_DBEAVER_STORAGE_FULL_ACCESS = "dbeaver-storage:full_access"; + public static final String SCOPE_ADMIN_FULL_ACCESS = "admin:full_access"; public static final String SCOPE_MCP_CONNECT = "mcp:connect"; public static final String SCOPE_MCP_INVOKE = "mcp:invoke"; public static final String TOKEN_USE_ACCESS = "access"; diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoAccountClient.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoAccountClient.java index e56b896..c36d5b5 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoAccountClient.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoAccountClient.java @@ -18,7 +18,6 @@ import com.dbeaver.datadam.sso.api.DDSsoConstants; import com.dbeaver.datadam.sso.api.exception.DDSsoClientException; -import com.dbeaver.datadam.sso.api.exception.DDSsoInsufficientScopeException; import com.dbeaver.datadam.sso.api.model.DDSsoAccessContextResponse; import com.dbeaver.rest.client.AbstractRestClient; import com.dbeaver.rest.client.MediaType; @@ -73,8 +72,6 @@ public DDSsoAccessContextResponse exchange(@NotNull String accessToken) throws D UUID.fromString(context.userId()); UUID.fromString(context.accountId()); return context; - } catch (InsufficientScopeResponse e) { - throw new DDSsoInsufficientScopeException(); } catch (DBException | IllegalArgumentException e) { throw new DDSsoClientException("Account service is unavailable"); } @@ -83,12 +80,6 @@ public DDSsoAccessContextResponse exchange(@NotNull String accessToken) throws D @NotNull @Override protected DBException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) { - return code == 403 ? new InsufficientScopeResponse() : new DBException("Account access context unavailable"); - } - - private static final class InsufficientScopeResponse extends DBException { - private InsufficientScopeResponse() { - super("Insufficient storage access scope"); - } + return new DBException("Account access context unavailable"); } } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/exception/DDSsoInsufficientScopeException.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/exception/DDSsoInsufficientScopeException.java deleted file mode 100644 index 6e59906..0000000 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/exception/DDSsoInsufficientScopeException.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * DBeaver - Universal Database Manager - * Copyright (C) 2010-2026 DBeaver Corp - * - * All Rights Reserved. - * - * NOTICE: All information contained herein is, and remains - * the property of DBeaver Corp and its suppliers, if any. - * The intellectual and technical concepts contained - * herein are proprietary to DBeaver Corp and its suppliers - * and may be covered by U.S. and Foreign Patents, - * patents in process, and are protected by trade secret or copyright law. - * Dissemination of this information or reproduction of this material - * is strictly forbidden unless prior written permission is obtained - * from DBeaver Corp. - */ -package com.dbeaver.datadam.sso.api.exception; - -/** A valid token cannot be used for the requested storage access context. */ -public class DDSsoInsufficientScopeException extends DDSsoClientException { - public DDSsoInsufficientScopeException() { - super("Insufficient storage access scope"); - } -} diff --git a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java index b797253..5e81e70 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java +++ b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java @@ -393,12 +393,12 @@ void credentialBearingClientsDoNotFollowRedirects() throws Exception { } @Test - void accountBackchannelPreservesInsufficientScopeResponse() { - responseJson.set("{\"error\":\"insufficient_scope\"}"); + void accountBackchannelRejectsForbiddenResponses() { + responseJson.set("{\"error\":\"forbidden\"}"); responseStatus.set(403); var account = new DDSsoAccountClient("http://127.0.0.1:" + server.getAddress().getPort(), "s".repeat(32)); Assertions.assertThatThrownBy(() -> account.exchange("header.payload.signature")) - .isInstanceOf(com.dbeaver.datadam.sso.api.exception.DDSsoInsufficientScopeException.class); + .isInstanceOf(DDSsoClientException.class); } @Test From 94075a9c14ea976238c1a4a653ebb848da58fe95 Mon Sep 17 00:00:00 2001 From: Aleksandr Skoblikov Date: Fri, 23 Oct 2026 17:30:38 +0300 Subject: [PATCH 4/7] dbeaver/pro#11122 Clarify code exchange method contracts --- README.md | 3 +++ .../datadam/sso/api/client/DDSsoClient.java | 3 ++- .../datadam/sso/api/client/DDSsoService.java | 8 ++++++-- .../datadam/sso/api/client/DDSsoServiceTest.java | 14 +++++++------- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e297a1a..9346f20 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ Every successful exchange returns an access JWT. Ordinary DataDam APIs use the f MCP access uses its authorized resource URI as `aud`. Account/Admin additionally use their internal identity JWT to establish local browser sessions; this is not an OpenID Connect contract. +`DDSsoClient.exchangeCodeForTokens` returns the complete token response with an optional `id_token`, without JWT verification. +`DDSsoService.exchangeCodeForIdentity` requires an internal `id_token`, verifies its signature and identity claims, +and returns `DDSsoIdentity` for browser login. `DDSsoTokenVerifier.verifyAccessToken` requires the expected audience explicitly. API authentication intersects current database permissions with the permissions mapped from the token's individual or full-access scopes. Controllers use ordinary permission checks. Identity tokens are never API credentials. diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java index 036acc1..2d68256 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java @@ -68,8 +68,9 @@ private static URI endpoint(@NotNull String base, @NotNull String path) { return URI.create(base + (base.endsWith("/") ? "" : "/") + (path.startsWith("/") ? path.substring(1) : path)); } + /** Exchanges an authorization code for the token response. JWTs are unverified and id_token is optional. */ @NotNull - public DDSsoTokenResponse exchange(@NotNull DDSsoTokenRequest request) throws DDSsoClientException { + public DDSsoTokenResponse exchangeCodeForTokens(@NotNull DDSsoTokenRequest request) throws DDSsoClientException { Map parameters = new HashMap<>(Map.of( OAuthConstants.PARAM_GRANT_TYPE, OAuthConstants.GRANT_TYPE_AUTH_CODE, OAuthConstants.PARAM_CODE, request.code(), OAuthConstants.PARAM_CODE_VERIFIER, request.codeVerifier(), diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoService.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoService.java index ba007a1..e0af9ea 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoService.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoService.java @@ -49,9 +49,13 @@ public URI endpoint(@NotNull String path) { return client.endpoint(path); } + /** + * Exchanges an authorization code for a verified identity used to establish a local browser session. + * Requires an internal id_token; its signature and identity claims are validated before returning. + */ @NotNull - public DDSsoIdentity exchange(@NotNull String code, @NotNull String codeVerifier) throws DDSsoClientException { - DDSsoTokenResponse token = client.exchange(new DDSsoTokenRequest(code, codeVerifier, properties.clientId(), + public DDSsoIdentity exchangeCodeForIdentity(@NotNull String code, @NotNull String codeVerifier) throws DDSsoClientException { + DDSsoTokenResponse token = client.exchangeCodeForTokens(new DDSsoTokenRequest(code, codeVerifier, properties.clientId(), properties.redirectUri(), properties.resource())); if (token.idToken() == null || token.idToken().length() > DDSsoTokenVerifier.MAX_TOKEN_LENGTH || token.expiresIn() <= 0) { throw new DDSsoClientException("Invalid SSO token response"); diff --git a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java index 5e81e70..7ec7bb9 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java +++ b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java @@ -242,7 +242,7 @@ void validatesTokenAndCachesTrustedJwks() throws Exception { @Test void tokenTransportOmitsAbsentResource() throws Exception { responseJson.set("{\"access_token\":\"test-token\",\"token_type\":\"Bearer\",\"expires_in\":60,\"scope\":\"account:access\"}"); - http.exchange(new com.dbeaver.datadam.sso.api.model.DDSsoTokenRequest( + http.exchangeCodeForTokens(new com.dbeaver.datadam.sso.api.model.DDSsoTokenRequest( "test-code", "A".repeat(43), "account", "https://account.example.com/sso/callback", null)); Assertions.assertThat(posted.get()).contains("grant_type=authorization_code", "code=test-code").doesNotContain("resource"); } @@ -380,7 +380,7 @@ void credentialBearingClientsDoNotFollowRedirects() throws Exception { responseJson.set("{}"); responseStatus.set(307); redirect.set("http://127.0.0.1:" + destination.getAddress().getPort()); - Assertions.assertThatThrownBy(() -> http.exchange(new DDSsoTokenRequest( + Assertions.assertThatThrownBy(() -> http.exchangeCodeForTokens(new DDSsoTokenRequest( "code", "verifier", "account", "https://account.example.com/sso/callback", null))) .isInstanceOf(DDSsoClientException.class); var account = new DDSsoAccountClient("http://127.0.0.1:" + server.getAddress().getPort(), "s".repeat(32)); @@ -405,7 +405,7 @@ void accountBackchannelRejectsForbiddenResponses() { void httpClientDeserializesTheEntireResponse() throws Exception { String token = "t".repeat(1_048_576); responseJson.set(gson.toJson(Map.of("id_token", token, "expires_in", 300))); - var response = http.exchange(new DDSsoTokenRequest( + var response = http.exchangeCodeForTokens(new DDSsoTokenRequest( "code", "verifier", "account", "https://account.example.com/sso/callback", null)); Assertions.assertThat(response.idToken()).isEqualTo(token); Assertions.assertThat(jwksRequests).hasValue(0); @@ -414,7 +414,7 @@ void httpClientDeserializesTheEntireResponse() throws Exception { @Test void httpClientReturnsUnverifiedTokenWithoutFetchingSigningKeys() throws Exception { responseJson.set(gson.toJson(Map.of("id_token", "opaque-unverified-token", "expires_in", 300))); - var response = http.exchange(new DDSsoTokenRequest("code", "verifier", "account", "https://account.example.com/sso/callback", null)); + var response = http.exchangeCodeForTokens(new DDSsoTokenRequest("code", "verifier", "account", "https://account.example.com/sso/callback", null)); Assertions.assertThat(response.idToken()).isEqualTo("opaque-unverified-token"); Assertions.assertThat(response.expiresIn()).isEqualTo(300); Assertions.assertThat(jwksRequests).hasValue(0); @@ -425,7 +425,7 @@ void serviceRejectsExpiredIdentityReturnedBySuccessfulHttpExchange() throws Exce var expired = claims(); expired.put("exp", NOW.getEpochSecond()); responseJson.set(gson.toJson(Map.of("id_token", signed(expired, header("key1")), "expires_in", 300))); - Assertions.assertThatThrownBy(() -> service.exchange("code", "verifier")) + Assertions.assertThatThrownBy(() -> service.exchangeCodeForIdentity("code", "verifier")) .isInstanceOf(DDSsoClientException.class).hasMessage("Invalid SSO identity token"); Assertions.assertThat(jwksRequests).hasValue(1); } @@ -433,7 +433,7 @@ void serviceRejectsExpiredIdentityReturnedBySuccessfulHttpExchange() throws Exce @Test void exchangesCodeAndVerifierOnlyInAuthenticatedFormBody() throws Exception { responseJson.set(gson.toJson(Map.of("id_token", signed(claims(), header("key1")), "expires_in", 300))); - Assertions.assertThat(service.exchange("code+/%", "verifier")).isNotNull(); + Assertions.assertThat(service.exchangeCodeForIdentity("code+/%", "verifier")).isNotNull(); Assertions.assertThat(requestMethod).hasValue("POST"); Assertions.assertThat(requestPath).hasValue("/token"); Assertions.assertThat(contentType).hasValue("application/x-www-form-urlencoded"); @@ -459,7 +459,7 @@ void preservesExternalSourceAndSubjectAfterVerifyingIdentityToken() throws Excep claims.put("sub", "wordpress-user-id"); responseJson.set(gson.toJson(Map.of("id_token", signed(claims, header("key1")), "expires_in", 300))); - DDSsoIdentity identity = service.exchange("code", "verifier"); + DDSsoIdentity identity = service.exchangeCodeForIdentity("code", "verifier"); Assertions.assertThat(identity.source()).isEqualTo("dbeaver-com"); Assertions.assertThat(identity.subject()).isEqualTo("wordpress-user-id"); From 5d019511e8597cb154cdc785c41f138e7e993256 Mon Sep 17 00:00:00 2001 From: Aleksandr Skoblikov Date: Fri, 23 Oct 2026 18:07:06 +0300 Subject: [PATCH 5/7] dbeaver/pro#11122 Clarify desktop and external MCP resource policy --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9346f20..d64195a 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Token exchange uses `POST /token` with `application/x-www-form-urlencoded` field `client_id`, `redirect_uri`, optional `resource` and mandatory `grant_type=authorization_code`. Clients with a configured secret authenticate through HTTP Basic; public clients use code and PKCE without credentials. Every successful exchange returns an access JWT. Ordinary DataDam APIs use the fixed `DDSsoConstants.API_AUDIENCE`; -MCP access uses its authorized resource URI as `aud`. +External MCP access uses its authorized resource URI as `aud`. Account/Admin additionally use their internal identity JWT to establish local browser sessions; this is not an OpenID Connect contract. `DDSsoClient.exchangeCodeForTokens` returns the complete token response with an optional `id_token`, without JWT verification. @@ -54,8 +54,10 @@ Failed exchanges return a JSON `error` (`invalid_request`, `unsupported_grant_ty Authorization uses `response_type=code`, space-separated `scope`, and S256 PKCE. Ordinary API access uses the client's top-level `scopes` without any resource configuration or request parameter. -MCP access uses `allowed-resources` with its own scope sets. During code exchange a supplied resource must match the saved grant. -MCP requires `resource` in both requests, configured with `require-resource: true` on its client resource entries. +Desktop requests `mcp:connect` alongside `account:full_access` and `dbeaver-storage:full_access` in one authorization, +without `resource`. External MCP clients use `allowed-resources` with `mcp:invoke` and `require-resource: true`; +their requests require `resource` at both endpoints. The requirement is registration-specific, not inferred from a scope name. +During code exchange a supplied resource must match the saved grant. SSO publishes RFC 8414 metadata at `/.well-known/oauth-authorization-server` (with the issuer path appended to that suffix when applicable). Browser callbacks contain `iss` per RFC 9207: clients compare it exactly to the issuer selected before authorization. Scopes follow RFC 6749 syntax, parsed by `DDSsoScopes`. From 574497fa307713a60faf50ccf2fbca875b58c37c Mon Sep 17 00:00:00 2001 From: Aleksandr Skoblikov Date: Fri, 23 Oct 2026 23:34:21 +0300 Subject: [PATCH 6/7] dbeaver/pro#11122 Encode OAuth credentials and allow omitted token redirect URI --- README.md | 7 +++++-- .../datadam/sso/api/client/DDSsoClient.java | 11 +++++++++-- .../sso/api/model/DDSsoTokenRequest.java | 2 +- .../sso/api/client/DDSsoServiceTest.java | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d64195a..934ec50 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,11 @@ The identity's `authTime` uses ISO-8601 (`GsonUtils.InstantIsoAdapter`). Browser `GET /handoff?handoff=...` and requires the proof cookie set by `/handoff/start`. Token exchange uses `POST /token` with `application/x-www-form-urlencoded` fields `code`, `code_verifier`, -`client_id`, `redirect_uri`, optional `resource` and mandatory `grant_type=authorization_code`. Clients with a -configured secret authenticate through HTTP Basic; public clients use code and PKCE without credentials. +`client_id`, optional `redirect_uri` and `resource`, and mandatory `grant_type=authorization_code`. +If supplied, `redirect_uri` must exactly match the original callback. External MCP grants require `resource`. +Clients with a configured secret authenticate through HTTP Basic and may omit the body `client_id`; +form-encode each credential before joining them with `:` and Base64-encoding the result (RFC 6749 section 2.3.1). +Public clients supply `client_id` and use code and PKCE without credentials. Every successful exchange returns an access JWT. Ordinary DataDam APIs use the fixed `DDSsoConstants.API_AUDIENCE`; External MCP access uses its authorized resource URI as `aud`. Account/Admin additionally use their diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java index 2d68256..436b5b4 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java @@ -36,6 +36,7 @@ import java.io.IOException; import java.lang.reflect.Type; import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.nio.charset.StandardCharsets; @@ -74,7 +75,10 @@ public DDSsoTokenResponse exchangeCodeForTokens(@NotNull DDSsoTokenRequest reque Map parameters = new HashMap<>(Map.of( OAuthConstants.PARAM_GRANT_TYPE, OAuthConstants.GRANT_TYPE_AUTH_CODE, OAuthConstants.PARAM_CODE, request.code(), OAuthConstants.PARAM_CODE_VERIFIER, request.codeVerifier(), - OAuthConstants.AUTH_PROP_CLIENT_ID, request.clientId(), OAuthConstants.PARAM_REDIRECT_URI, request.redirectUri())); + OAuthConstants.AUTH_PROP_CLIENT_ID, request.clientId())); + if (request.redirectUri() != null) { + parameters.put(OAuthConstants.PARAM_REDIRECT_URI, request.redirectUri()); + } if (request.resource() != null) { parameters.put(DDSsoConstants.PARAM_RESOURCE, request.resource()); } @@ -122,8 +126,11 @@ private T postResponse(@NotNull String path, @NotNull Object body, @NotNull @Override protected T execute(@NotNull HttpRequest.Builder builder, @NotNull Type type) throws DBException { if (HttpConstants.METHOD_POST.equals(builder.build().method())) { + // RFC 6749 section 2.3.1: form-encode each component before joining the Basic credentials. + String credentials = URLEncoder.encode(properties.clientId(), StandardCharsets.UTF_8) + ":" + + URLEncoder.encode(properties.clientSecret(), StandardCharsets.UTF_8); builder.setHeader(HttpConstants.HEADER_AUTHORIZATION, HttpConstants.BASIC_PREFIX - + Base64.getEncoder().encodeToString((properties.clientId() + ":" + properties.clientSecret()).getBytes(StandardCharsets.UTF_8))); + + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8))); } return super.execute(builder, type); } diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoTokenRequest.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoTokenRequest.java index 0b10916..6f2e46e 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoTokenRequest.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoTokenRequest.java @@ -28,7 +28,7 @@ public record DDSsoTokenRequest( @NotNull String code, @SerializedName(OAuthConstants.PARAM_CODE_VERIFIER) @NotNull String codeVerifier, @SerializedName(OAuthConstants.AUTH_PROP_CLIENT_ID) @NotNull String clientId, - @SerializedName(OAuthConstants.PARAM_REDIRECT_URI) @NotNull String redirectUri, + @SerializedName(OAuthConstants.PARAM_REDIRECT_URI) @Nullable String redirectUri, @Nullable String resource ) { } diff --git a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java index 7ec7bb9..9db2e4f 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java +++ b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java @@ -452,6 +452,25 @@ void exchangesCodeAndVerifierOnlyInAuthenticatedFormBody() throws Exception { )); } + @Test + void httpClientOmitsOptionalRedirectUri() throws Exception { + responseJson.set(gson.toJson(Map.of("access_token", "access", "expires_in", 300))); + Assertions.assertThat(http.exchangeCodeForTokens(new DDSsoTokenRequest("code", "verifier", "account", null, null)).accessToken()) + .isEqualTo("access"); + Assertions.assertThat(posted.get().split("&")) + .containsExactlyInAnyOrder("grant_type=authorization_code", "code=code", "code_verifier=verifier", "client_id=account"); + } + + @Test + void httpClientFormEncodesEachBasicCredentialBeforeBase64() throws Exception { + var config = new DDSsoClientConfig("https://sso.example.com", "http://127.0.0.1:" + server.getAddress().getPort(), + "client:+ %é", "secret:+ %ß", "https://account.example.com/sso/callback", "account", Duration.ofMinutes(5), null, "account:access"); + responseJson.set(gson.toJson(Map.of("access_token", "access", "expires_in", 300))); + new DDSsoClient(config).exchangeCodeForTokens(new DDSsoTokenRequest("code", "verifier", config.clientId(), null, null)); + Assertions.assertThat(credentials.get()).isEqualTo("Basic " + Base64.getEncoder().encodeToString( + "client%3A%2B+%25%C3%A9:secret%3A%2B+%25%C3%9F".getBytes(StandardCharsets.UTF_8))); + } + @Test void preservesExternalSourceAndSubjectAfterVerifyingIdentityToken() throws Exception { Map claims = claims(); From edb89d1b81f876197f140ac0777a9d0977e2383f Mon Sep 17 00:00:00 2001 From: Aleksandr Skoblikov Date: Mon, 26 Oct 2026 12:53:59 +0300 Subject: [PATCH 7/7] dbeaver/pro#11122 Fix SSO Java style and nullability annotations --- .../sso/api/client/DDSsoClientConfig.java | 1 + .../datadam/sso/api/DDSsoScopesTest.java | 3 ++- .../sso/api/client/DDSsoServiceTest.java | 18 ++++++++++-------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClientConfig.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClientConfig.java index 42fdfb9..547394a 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClientConfig.java +++ b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClientConfig.java @@ -34,6 +34,7 @@ public record DDSsoClientConfig( @NotNull String scope ) { @Override + @NotNull public String toString() { return "DDSsoClientConfig[issuer=" + issuer + ", clientId=" + clientId + "]"; } diff --git a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/DDSsoScopesTest.java b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/DDSsoScopesTest.java index 229b2b1..f61d43d 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/DDSsoScopesTest.java +++ b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/DDSsoScopesTest.java @@ -17,6 +17,7 @@ package com.dbeaver.datadam.sso.api; import org.assertj.core.api.Assertions; +import org.jkiss.code.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; @@ -37,7 +38,7 @@ void scopesAreCaseSensitiveAndHaveAStableRepresentation() { @NullAndEmptySource @ValueSource(strings = {" ", " account:access", "account:access ", "account:access dbeaver-storage:access", "account:access\tdbeaver-storage:access", "account:access\n", "a\"b", "a\\b", "права"}) - void rejectsInvalidWireScopes(String scope) { + void rejectsInvalidWireScopes(@Nullable String scope) { Assertions.assertThatThrownBy(() -> DDSsoScopes.parse(scope)).isInstanceOf(IllegalArgumentException.class); } } diff --git a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java index 9db2e4f..b6a960a 100644 --- a/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java +++ b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/client/DDSsoServiceTest.java @@ -16,9 +16,9 @@ */ package com.dbeaver.datadam.sso.api.client; -import com.dbeaver.datadam.sso.api.exception.DDSsoClientException; import com.dbeaver.datadam.sso.api.DDSsoConstants; -import org.assertj.core.api.Assertions; +import com.dbeaver.datadam.sso.api.exception.DDSsoClientException; +import com.dbeaver.datadam.sso.api.model.DDSsoAccessIdentity; import com.dbeaver.datadam.sso.api.model.DDSsoAuthorizeRequest; import com.dbeaver.datadam.sso.api.model.DDSsoHandoffRequest; import com.dbeaver.datadam.sso.api.model.DDSsoIdentity; @@ -26,10 +26,10 @@ import com.dbeaver.datadam.sso.api.model.DDSsoLoginApprovalRequest; import com.dbeaver.datadam.sso.api.model.DDSsoPublicJwk; import com.dbeaver.datadam.sso.api.model.DDSsoTokenRequest; -import com.dbeaver.datadam.sso.api.model.DDSsoAccessIdentity; import com.google.gson.Gson; import com.google.gson.JsonParser; import com.sun.net.httpserver.HttpServer; +import org.assertj.core.api.Assertions; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; @@ -37,9 +37,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import java.net.InetSocketAddress; @@ -175,7 +175,7 @@ void approvesLoginOverAuthenticatedBackchannelAndResumesOnPublicIssuer() throws @ParameterizedTest @ValueSource(strings = {"{}", "{\"completion\":null}", "{\"completion\":\"\"}", "{\"completion\":\"unsafe&state=other\"}"}) - void rejectsInvalidLoginCompletionsBeforeBuildingRedirect(String json) { + void rejectsInvalidLoginCompletionsBeforeBuildingRedirect(@NotNull String json) { responseJson.set(json); Assertions.assertThatThrownBy(() -> service.approveLogin(new DDSsoLoginApprovalRequest("B".repeat(43), new DDSsoIdentity("user-id", "user@example.com", "datadam-account", NOW)))) @@ -184,7 +184,7 @@ void rejectsInvalidLoginCompletionsBeforeBuildingRedirect(String json) { @ParameterizedTest @ValueSource(strings = {"dbeaver-desktop", "account", "external-client"}) - void acceptsAccessTokensForAnyClientButOnlyTheExpectedAudienceAndPurpose(String clientId) throws Exception { + void acceptsAccessTokensForAnyClientButOnlyTheExpectedAudienceAndPurpose(@NotNull String clientId) throws Exception { var claims = claims(); claims.put("aud", DDSsoConstants.API_AUDIENCE); claims.put("token_use", DDSsoConstants.TOKEN_USE_ACCESS); @@ -414,7 +414,8 @@ void httpClientDeserializesTheEntireResponse() throws Exception { @Test void httpClientReturnsUnverifiedTokenWithoutFetchingSigningKeys() throws Exception { responseJson.set(gson.toJson(Map.of("id_token", "opaque-unverified-token", "expires_in", 300))); - var response = http.exchangeCodeForTokens(new DDSsoTokenRequest("code", "verifier", "account", "https://account.example.com/sso/callback", null)); + var response = http.exchangeCodeForTokens(new DDSsoTokenRequest( + "code", "verifier", "account", "https://account.example.com/sso/callback", null)); Assertions.assertThat(response.idToken()).isEqualTo("opaque-unverified-token"); Assertions.assertThat(response.expiresIn()).isEqualTo(300); Assertions.assertThat(jwksRequests).hasValue(0); @@ -464,7 +465,8 @@ void httpClientOmitsOptionalRedirectUri() throws Exception { @Test void httpClientFormEncodesEachBasicCredentialBeforeBase64() throws Exception { var config = new DDSsoClientConfig("https://sso.example.com", "http://127.0.0.1:" + server.getAddress().getPort(), - "client:+ %é", "secret:+ %ß", "https://account.example.com/sso/callback", "account", Duration.ofMinutes(5), null, "account:access"); + "client:+ %é", "secret:+ %ß", "https://account.example.com/sso/callback", "account", + Duration.ofMinutes(5), null, "account:access"); responseJson.set(gson.toJson(Map.of("access_token", "access", "expires_in", 300))); new DDSsoClient(config).exchangeCodeForTokens(new DDSsoTokenRequest("code", "verifier", config.clientId(), null, null)); Assertions.assertThat(credentials.get()).isEqualTo("Basic " + Base64.getEncoder().encodeToString(