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
8 changes: 8 additions & 0 deletions backend/druks/services/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] = ""
Expand Down Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions backend/druks/services/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

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

Expand All @@ -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 = (
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions backend/tests/test_oauth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion backend/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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")

Expand Down
10 changes: 9 additions & 1 deletion docs/writing-an-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down