Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ jobs:

macos-pkg-smoke:
name: macOS existing PKG smoke
runs-on: macos-14
runs-on: macos-15-intel
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
matrix:
include:
- name: macos-universal2
os: macos-14
os: macos-15-intel
macos_arch: universal2
- name: windows-x64
os: windows-latest
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,29 @@ Set the API base URL if you are not using the default:
dataplicity config set --base-url https://gateway.dataplicity.com
```

Use HTTPS for remote API servers; HTTP is supported for loopback development.
Changing the saved server clears its previous authentication. A one-command
`--base-url` override uses no credentials saved for a different server; sign in
to the selected server before using protected commands. An override alone does
not overwrite the original configuration file.

Commands retain the sign-in they started with. If another process signs in or
out while a command is waiting, the older command stops using that session and
asks you to start again. Configuration updates use a private atomic file and a
short process lock; a delayed renewal cannot overwrite a newer sign-in. A lost
renewal response can be retried with the saved recovery identifier.

`dataplicity auth logout` clears saved credentials before contacting the server.
For a Core user login it revokes only that sign-in and its rotated credentials;
other apps/devices remain signed in. Use `dataplicity auth logout --everywhere`
to explicitly revoke all sign-ins on the account. That action exits unsuccessfully
if server revocation is unavailable or cannot be confirmed, even though local
credentials have been cleared. Neither action renews credentials to sign out. A delayed
reply cannot overwrite a replacement login. The CLI reports if server sign-out
could not be confirmed. Organisation API keys are only removed locally.
Server revocation requires the matching backend session-authority release;
an already-consumed refresh is accepted only as revocation proof.

Login with email/password:

```
Expand All @@ -113,6 +136,12 @@ SSO login:
dataplicity auth sso --email you@example.com
```

On remote terminals, use `--no-open-browser` to open the printed sign-in link
on your workstation, then paste the final code/state callback when prompted.
The CLI keeps its PKCE verifier locally and exchanges the code at the configured
HTTPS identity origin. Browser callbacks contain no access or refresh tokens.
This login flow requires the coordinated native-code backend/frontend release.

Use an organisation API key:

```
Expand Down
47 changes: 47 additions & 0 deletions dataplicity_cli/account_logout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Explicit Core sign-in revocation, independent of the detached local owner."""
import time

import requests

from .api import _EXPLICIT_API_AUTH
from .api_recipient import recipient_url
from .config import LogoutProof


def revoke_account(proof: LogoutProof, *, everywhere: bool = False) -> str:
"""Never renew, follow redirects, borrow cookies, or write credentials.

The paired backend accepts a consumed refresh only as revocation proof.
A network/server failure is ambiguous and must not start another mutation.
"""
if not proof.access and not proof.refresh:
return 'unavailable'
try:
path = '/api/auth/logout/everywhere/' if everywhere else '/api/auth/logout/'
url = recipient_url(proof.base_url, path)
except ValueError:
return 'unavailable'
deadline = time.monotonic() + 3.0
session = requests.Session()
try:
for access in ((proof.access, None) if proof.access and proof.refresh else (proof.access,)):
remaining = deadline - time.monotonic()
if remaining <= 0:
break
session.cookies.clear()
response = session.post(url,
headers={'Authorization': 'Bearer ' + access} if access else {},
json={} if access else {'refresh': proof.refresh},
auth=_EXPLICIT_API_AUTH, allow_redirects=False, timeout=remaining, stream=True)
try:
if 200 <= response.status_code < 300:
return 'revoked'
if response.status_code not in (401, 403):
break
finally:
response.close()
except requests.RequestException:
pass
finally:
session.close()
return 'failed'
Loading
Loading