diff --git a/README.md b/README.md index d3ecb3e..934ec50 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,38 @@ 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 -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. +`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 +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. +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` 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. +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`. ## 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 0b483a4..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 @@ -23,8 +23,10 @@ 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 METADATA_PATH = "/.well-known/oauth-authorization-server"; public static final String HEALTH_PATH = "/health"; public static final String PARAM_FLOW_ID = "flow_id"; @@ -32,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"; @@ -39,10 +45,12 @@ 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"; - 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"; @@ -88,12 +96,20 @@ 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_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"; 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/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/DDSsoClient.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClient.java index 7526a80..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 @@ -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; @@ -31,15 +31,18 @@ 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; import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; 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; @@ -66,9 +69,20 @@ 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 { - return postResponse(DDSsoConstants.TOKEN_PATH, request, MediaType.FORM_URLENCODED, + 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(), + 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()); + } + return postResponse(DDSsoConstants.TOKEN_PATH, parameters, MediaType.FORM_URLENCODED, DDSsoTokenResponse.class, "Invalid SSO token response"); } @@ -79,9 +93,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 @@ -112,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/client/DDSsoClientConfig.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/client/DDSsoClientConfig.java index 3337ead..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 @@ -29,9 +29,12 @@ 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 + @NotNull public String toString() { return "DDSsoClientConfig[issuer=" + issuer + ", clientId=" + clientId + "]"; } 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..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 @@ -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; @@ -49,9 +49,14 @@ 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(), properties.redirectUri())); + 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"); } @@ -69,11 +74,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/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/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/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/DDSsoDesktopTokenResponse.java b/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopTokenResponse.java deleted file mode 100644 index 4086950..0000000 --- a/apis/com.dbeaver.datadam.sso.api/src/main/java/com/dbeaver/datadam/sso/api/model/DDSsoDesktopTokenResponse.java +++ /dev/null @@ -1,31 +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.model; - -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 -) { -} 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/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..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 @@ -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) @Nullable 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..f61d43d --- /dev/null +++ b/apis/com.dbeaver.datadam.sso.api/src/test/java/com/dbeaver/datadam/sso/api/DDSsoScopesTest.java @@ -0,0 +1,44 @@ +/* + * 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.jkiss.code.Nullable; +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(@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 895027d..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,19 +16,20 @@ */ 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; 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; 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; @@ -36,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; @@ -113,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); @@ -135,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); } @@ -145,33 +146,87 @@ 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"); } @Test - void acceptsOnlyDesktopAccessTokensWithApiAudienceAndAccessPurpose() throws Exception { + 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(@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)))) + .isInstanceOf(DDSsoClientException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"dbeaver-desktop", "account", "external-client"}) + void acceptsAccessTokensForAnyClientButOnlyTheExpectedAudienceAndPurpose(@NotNull 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 @@ -184,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.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"); + } + @Test void verifiesOriginalEncodedSegmentsWithWhitespaceUnicodeAndArrayAudience() throws Exception { Map claims = claims(); @@ -317,8 +380,8 @@ 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( - "code", "verifier", "account", "https://account.example.com/sso/callback"))) + 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)); Assertions.assertThatThrownBy(() -> account.exchange("test-access-token")) @@ -329,12 +392,21 @@ void credentialBearingClientsDoNotFollowRedirects() throws Exception { } } + @Test + 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(DDSsoClientException.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")); + 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); } @@ -342,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.exchange(new DDSsoTokenRequest("code", "verifier", "account", "https://account.example.com/sso/callback")); + 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); @@ -353,7 +426,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); } @@ -361,7 +434,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"); @@ -373,12 +446,33 @@ 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 )); } + @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(); @@ -386,7 +480,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"); @@ -397,7 +491,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");