From 7e56289d591522d05c83ddb54624664a5a4599a1 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sat, 22 Aug 2026 07:59:52 +0200 Subject: [PATCH] A service declares its provider's consent-query quirks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google grants a refresh token only when the consent URL carries access_type=offline and prompt=consent. Until now the only door was subclassing OauthClient and overriding get_oauth_client(), duplicating the whole base construction just to swap the class. OauthClient takes extra_authorize_params and lands them in every consent query it begins; begin_connect's own argument layers over them on a shared key. Service declares them as a ClassVar and get_oauth_client() passes them through, so an extension states the quirk in one line next to its endpoints. Connection also exposes identity — the provider's facts for the sign-in — so extensions read it off the handle, not the row. --- backend/druks/services/base.py | 8 ++++++++ backend/druks/services/oauth.py | 12 ++++++++---- backend/tests/test_oauth_client.py | 12 ++++++++++++ backend/tests/test_services.py | 9 ++++++++- docs/writing-an-extension.md | 10 +++++++++- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/backend/druks/services/base.py b/backend/druks/services/base.py index 179897c9..dde80cb5 100644 --- a/backend/druks/services/base.py +++ b/backend/druks/services/base.py @@ -29,6 +29,10 @@ def id(self) -> str: def scopes(self) -> list[str]: return self.row.scopes + @property + def identity(self) -> dict[str, Any]: + return self.row.identity + @property def connected_at(self): return self.row.connected_at @@ -99,6 +103,9 @@ class Service: token_endpoint: ClassVar[str] = "" # HTTP Basic on the token endpoint; False sends the secret in the body. basic_auth: ClassVar[bool] = False + # The provider's consent-query quirks — Google grants a refresh token + # only with access_type=offline and prompt=consent. + extra_authorize_params: ClassVar[dict[str, str]] = {} # The endpoint that returns the signed-in account's facts; # identity_scopes join the consent ask. identity_endpoint: ClassVar[str] = "" @@ -203,6 +210,7 @@ def get_oauth_client(cls) -> OauthClient: client_id=connected.identity["client_id"], client_secret=connected.secrets["client_secret"], basic_auth=cls.basic_auth, + extra_authorize_params=cls.extra_authorize_params, ) @classmethod diff --git a/backend/druks/services/oauth.py b/backend/druks/services/oauth.py index c302d211..0ac1add3 100644 --- a/backend/druks/services/oauth.py +++ b/backend/druks/services/oauth.py @@ -77,7 +77,9 @@ class OauthClient: ``basic_auth`` picks HTTP Basic on the token endpoint, for both the code exchange and refresh; without it the client credentials travel in the form body. ``extra_token_params`` land in both bodies (RFC 8707's ``resource`` - audience binding). Scopes are per authorization, not per client — each + audience binding); ``extra_authorize_params`` land in every consent query + (Google grants a refresh token only with ``access_type=offline`` and + ``prompt=consent``). Scopes are per authorization, not per client — each ``begin_connect`` asks for its own, and the grant keeps what was approved. """ @@ -91,6 +93,7 @@ def __init__( client_secret: str = "", basic_auth: bool = False, extra_token_params: dict[str, str] | None = None, + extra_authorize_params: dict[str, str] | None = None, mint_wait_interval_seconds: float = OAUTH_MINT_WAIT_INTERVAL_SECONDS, mint_wait_attempts: int = OAUTH_MINT_WAIT_ATTEMPTS, ) -> None: @@ -101,6 +104,7 @@ def __init__( self.client_secret = client_secret self.basic_auth = basic_auth self.extra_token_params = dict(extra_token_params or {}) + self.extra_authorize_params = dict(extra_authorize_params or {}) self.mint_wait_interval_seconds = mint_wait_interval_seconds self.mint_wait_attempts = mint_wait_attempts @@ -117,8 +121,8 @@ async def begin_connect( query — this authorization's ask, within whatever ceiling the provider registration allows. ``context`` rides the stash and comes back from ``complete_connect``; ``extra_authorize_params`` land in the consent - query. Nothing durable is written here — an abandoned consent simply - expires.""" + query, over the client's declared ones on a shared key. Nothing durable + is written here — an abandoned consent simply expires.""" state = secrets.token_urlsafe(32) code_verifier = secrets.token_urlsafe(64) code_challenge = ( @@ -153,7 +157,7 @@ async def begin_connect( } if scopes: query["scope"] = " ".join(scopes) - query.update(extra_authorize_params or {}) + query.update({**self.extra_authorize_params, **(extra_authorize_params or {})}) return f"{self.authorization_endpoint}?{urlencode(query)}" async def get_access_token( diff --git a/backend/tests/test_oauth_client.py b/backend/tests/test_oauth_client.py index e9ac57d4..c4c79a8e 100644 --- a/backend/tests/test_oauth_client.py +++ b/backend/tests/test_oauth_client.py @@ -212,6 +212,18 @@ async def test_connect_roundtrip_exchanges_with_basic_auth(token_endpoint): assert token_endpoint.authorizations[0].startswith("Basic ") +async def test_begin_connect_call_params_override_the_declared_ones(): + client = _client(extra_authorize_params={"access_type": "offline", "prompt": "consent"}) + + url = await client.begin_connect( + redirect_uri=_REDIRECT_URI, extra_authorize_params={"prompt": "select_account"} + ) + + params = dict(parse_qsl(urlparse(url).query)) + assert params["access_type"] == "offline" + assert params["prompt"] == "select_account" + + async def test_complete_connect_requires_a_refresh_token(token_endpoint): token_endpoint.response = {"access_token": "at-1", "expires_in": 3600} url = await _client().begin_connect(redirect_uri=_REDIRECT_URI) diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index f6953606..7885b0fa 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -394,6 +394,7 @@ class Acme(Service): authorization_endpoint = "https://acme.test/authorize" token_endpoint = "https://acme.test/token" basic_auth = True + extra_authorize_params = {"access_type": "offline"} class Settings(BaseModel): client_id: str @@ -411,6 +412,7 @@ class Settings(BaseModel): assert client.client_id == "id-1" assert client.client_secret == "sec-1" assert client.basic_auth is True + assert client.extra_authorize_params == {"access_type": "offline"} def test_oauth_service_declarations_fail_loudly(declared_services): @@ -485,11 +487,16 @@ class Digest: from druks.services.models import OauthConnection row = OauthConnection.create( - provider="acme", account_id=SYSTEM_ACCOUNT_ID, refresh_token="rt-1", scopes=["profile.read"] + provider="acme", + account_id=SYSTEM_ACCOUNT_ID, + refresh_token="rt-1", + scopes=["profile.read"], + identity={"email": "night@acme.test"}, ) connections = NightWatch.acme.list_for_account(SYSTEM_ACCOUNT_ID) assert [connection.id for connection in connections] == [row.id] assert connections[0].scopes == ["profile.read"] + assert connections[0].identity == {"email": "night@acme.test"} assert NightWatch.acme.get(row.id).id == row.id assert not NightWatch.acme.get("missing") diff --git a/docs/writing-an-extension.md b/docs/writing-an-extension.md index 4edfb189..948f4225 100644 --- a/docs/writing-an-extension.md +++ b/docs/writing-an-extension.md @@ -752,12 +752,19 @@ class Acme(Service): basic_auth = True identity_endpoint = "https://acme.example/oauth/userinfo" identity_scopes = ("openid", "email") + # Query parameters the provider's consent URL must carry. + extra_authorize_params = {"access_type": "offline", "prompt": "consent"} class Settings(BaseModel): client_id: str = Field(title="Client ID") client_secret: SecretStr = Field(title="Client secret") ``` +`extra_authorize_params` declares the provider's consent-query quirks; the +platform adds them to every sign-in it starts for the service. The example +shows Google's: it grants a refresh token only when the consent asks for +`access_type=offline` with `prompt=consent`. + `identity_endpoint` names the provider endpoint that returns the signed-in account's facts (email, username, name). Druks calls it once at consent and shows the facts as the connection's label in Settings. `identity_scopes` @@ -793,7 +800,8 @@ for connection in NightWatch.acme.list_for_account(account_id): ``` `NightWatch.acme.get(connection_id)` returns one connection when your own -row stored its id. +row stored its id. Each connection carries `id`, `scopes`, `identity` — the +provider's facts for the sign-in — and `connected_at`. Your UI starts a sign-in by opening `/api/oauth/acme/connect` — the platform runs the consent with the union of every installed extension's