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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,34 @@ 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";
public static final String PARAM_HANDOFF = "handoff";
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";

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";

Expand Down Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> parse(@Nullable String scope) {
if (scope == null || scope.isEmpty() || scope.length() > 4096) {
throw new IllegalArgumentException("OAuth scopes are required");
}
Set<String> 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<String> scopes) {
String value = String.join(" ", new TreeSet<>(scopes));
parse(value);
return value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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<String, String> 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");
}

Expand All @@ -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
Expand Down Expand Up @@ -112,8 +126,11 @@ private <T> T postResponse(@NotNull String path, @NotNull Object body, @NotNull
@Override
protected <T> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "]";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
}
Expand All @@ -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();
}
}
Loading
Loading