Skip to content

Commit dd99f33

Browse files
committed
feat: add token update mechanism for new incoming OAuth2 refresh tokens
1 parent cc15cbc commit dd99f33

8 files changed

Lines changed: 127 additions & 41 deletions

File tree

docs/rest.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,33 @@ Retrieve OAuth2 client metadata for platforms that require dynamic registration
100100

101101
---
102102

103+
### 6. Publish Content
104+
105+
Submit an encrypted payload for publication to a target platform.
106+
107+
**URL:** `/publications`
108+
**Method:** `POST`
109+
110+
**Request Body:** `PublishContentRequest`
111+
112+
| Field | Type | Description |
113+
| :--- | :--- | :--- |
114+
| address | string | Sender phone number in E.164 format (e.g., `+12025550123`) |
115+
| text | string | Base64-encoded serialized payload |
116+
117+
**Response Body:** `PublishContentResponse`
118+
119+
| Field | Type | Description |
120+
| :--- | :--- | :--- |
121+
| message | string | (Optional) Confirmation message on success |
122+
| error | string | (Optional) Error description on failure |
123+
103124
## Error Handling
104125

105126
The API returns standard HTTP status codes:
106127

107128
- `200 OK`: Request successful.
129+
- `400 Bad Request`: Invalid request parameters or payload.
108130
- `404 Not Found`: Platform or Key not found.
109131
- `422 Unprocessable Entity`: Validation error in request parameters.
110132
- `500 Internal Server Error`: Unexpected server error.

models/token.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,13 @@ def create(
6666
def get_by_token_id(token_id: bytes, session: Session) -> Optional[Token]:
6767
"""Fetch a token by its public token_id."""
6868
return session.query(Token).filter(Token.token_id == token_id).first()
69+
70+
71+
def update_token_data(
72+
token: Token, new_token_data: dict[str, Any], session: Session
73+
) -> Token:
74+
"""Update the token_data of an existing token."""
75+
token.token_data = new_token_data
76+
session.add(token)
77+
session.flush()
78+
return token

rest_services/v1/routes.py

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import List
88

99
from fastapi import APIRouter, HTTPException, Path, Request
10-
from fastapi.responses import HTMLResponse
10+
from fastapi.responses import HTMLResponse, JSONResponse
1111

1212
from db import get_session
1313
from lib_relaysms_payload_specs.generated import relaysms_spec_payload as rrs
@@ -209,32 +209,34 @@ def create_publications(body: PublishContentRequest) -> PublishContentResponse:
209209
status_code=400, detail="Invalid base64-encoded text field"
210210
) from exc
211211

212-
try:
213-
payload = rrs.V1Payloads.deserialize(payload_raw)
214-
except Exception as exc:
215-
logger.error("failed to deserialize payload: %s", exc)
216-
raise HTTPException(
217-
status_code=400, detail="Failed to deserialize payload"
218-
) from exc
219-
220-
k_id = payload.get_kid()
221-
t_id = payload.get_t_id()
222-
t_id_bytes = struct.pack("<I", t_id)
223-
len_att = payload.get_len_att()
224-
sess_id = payload.get_sess_id()
225-
226-
if sess_id is None:
227-
with get_session() as db:
228-
publish_content(
229-
token_id=t_id_bytes,
230-
key_id=k_id,
231-
len_att=len_att,
232-
content_ciphertext=payload.get_payload(),
233-
session=db,
212+
payload_type = rrs.v1_get_payload_type(payload_raw)
213+
214+
match payload_type:
215+
case rrs.V1PayloadsTypes.WITHOUT_ATTACHMENT:
216+
try:
217+
payload = rrs.V1Payloads.deserialize_without_attachment(payload_raw)
218+
except Exception as exc:
219+
logger.exception("failed to deserialize payload: %s", exc)
220+
raise HTTPException(
221+
status_code=400, detail="Failed to deserialize payload"
222+
) from exc
223+
224+
k_id = payload.get_kid()
225+
t_id = payload.get_t_id()
226+
t_id_bytes = struct.pack("<I", t_id)
227+
len_att = payload.get_len_att()
228+
229+
with get_session() as db:
230+
publish_content(
231+
token_id=t_id_bytes,
232+
key_id=k_id,
233+
len_att=len_att,
234+
content_ciphertext=payload.get_content(),
235+
session=db,
236+
)
237+
case _:
238+
raise ValueError(
239+
f"Payload type {payload_type!r} not yet supported. Contact the developers for implementation status."
234240
)
235-
else:
236-
raise HTTPException(
237-
status_code=501, detail="Multi-segment payloads not yet supported"
238-
)
239241

240242
return PublishContentResponse(message="Content published successfully")

rest_services/v1/services.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
delete_by_index as delete_server_key_by_index,
1818
)
1919
from models.server_identity_key import mark_key_used as mark_ss_kid_used
20+
from models.token import update_token_data
2021
from platforms.adapter_ipc_handler import AdapterIPCHandler
2122

2223
logger = get_logger(__name__)
@@ -83,7 +84,7 @@ def publish_content(
8384
received_payload=content_ciphertext,
8485
)
8586
except Exception as exc:
86-
logger.error("decryption failed for key_id=%d: %s", key_id, exc)
87+
logger.exception("decryption failed for key_id=%d: %s", key_id, exc)
8788
raise ValueError("failed to decrypt content") from exc
8889

8990
_consume_used_keys(
@@ -93,13 +94,13 @@ def publish_content(
9394
try:
9495
cat_id = rrs.v1_content_category_from_u8(token.cat_id)
9596
except Exception as exc:
96-
logger.error("unknown cat_id=%r on token: %s", token.cat_id, exc)
97+
logger.exception("unknown cat_id=%r on token: %s", token.cat_id, exc)
9798
raise
9899

99100
try:
100101
content = rrs.V1ContentsContainer.deserialize(content_bytes, cat_id, len_att)
101102
except Exception as exc:
102-
logger.error("content deserialization failed: %s", exc)
103+
logger.exception("content deserialization failed: %s", exc)
103104
raise ValueError("failed to deserialize content") from exc
104105

105106
if token.protocol == "oauth2":
@@ -135,3 +136,17 @@ def publish_content(
135136
if pipe.get("error"):
136137
logger.error("failed to publish content: %s", pipe["error"])
137138
raise ValueError("failed to publish content")
139+
140+
result = pipe.get("result", {})
141+
142+
if token.protocol == "oauth2":
143+
refreshed_token = result.get("refreshed_token") or {}
144+
new_refresh_token = refreshed_token.get("refresh_token")
145+
old_refresh_token = token.token_data["token"].get("refresh_token")
146+
if new_refresh_token and new_refresh_token != old_refresh_token:
147+
update_token_data(
148+
token, {**token.token_data, "token": refreshed_token}, session
149+
)
150+
logger.info(
151+
"Successfully refreshed OAuth2 token for platform %r", token.platform
152+
)

tests/README.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ python -m tests.client sync-keys
6161
> [!NOTE]
6262
> If multiple tokens are stored, you will be prompted interactively to select one
6363
> or you can specify the token using the `--token` argument.
64-
64+
>
6565
### 4. Revoke OAuth2 Token
6666

6767
Revokes a stored OAuth2 token.
@@ -73,7 +73,7 @@ python -m tests.client revoke-oauth2-token
7373
> [!NOTE]
7474
> If multiple tokens are stored, you will be prompted interactively to select one
7575
> or you can specify the token using the `--token` argument.
76-
76+
>
7777
### 5. Get PNBA Code
7878

7979
Requests a passcode/OTP for Phone Number-Based Authentication (PNBA).
@@ -90,6 +90,12 @@ Exchanges the PNBA OTP code for a token, decrypts it, and stores the session dat
9090
python -m tests.client exchange-pnba-code --platform telegram --phone-number +123456789 --code <OTP_CODE>
9191
```
9292

93+
If the account has two-step verification enabled, re-run with `--password`:
94+
95+
```sh
96+
python -m tests.client exchange-pnba-code --platform telegram --phone-number +123456789 --code <OTP_CODE> --password <PASSWORD>
97+
```
98+
9399
### 7. Revoke PNBA Token
94100

95101
Revokes a stored PNBA token.
@@ -101,3 +107,29 @@ python -m tests.client revoke-pnba-token
101107
> [!NOTE]
102108
> If multiple tokens are stored, you will be prompted interactively to select one
103109
> or you can specify the token using the `--token` argument.
110+
111+
### 8. Send
112+
113+
Encrypts a message and publishes it to a platform via the REST `POST /v1/publications` endpoint. Constructs and serializes a `V1Payloads` payload, encrypts the content, and POSTs it with the sender's phone number as the `address`.
114+
115+
```sh
116+
python -m tests.client send --platform gmail --address +237123456789 --to friend@example.com --subject "Hello" --body "Test message"
117+
```
118+
119+
```sh
120+
python -m tests.client send --platform telegram --address +237123456789 --to +237123456789 --body "Hi there"
121+
```
122+
123+
**Arguments:**
124+
125+
| Argument | Required | Description |
126+
| :--- | :--- | :--- |
127+
| `--address` | Yes | Sender's phone number in E.164 format (e.g. `+237123456789`) |
128+
| `--body` | Yes | Message body |
129+
| `--to` | No | Recipient address (email or phone number). Required for email/messaging platforms. |
130+
| `--subject` | No | Message subject. Email platforms only. |
131+
| `--token` | No | Raw token (base64) to use. Omit for interactive prompt. |
132+
| `--dry-run` | No | Print the request body instead of sending it. |
133+
134+
> [!NOTE]
135+
> Each send consumes one ephemeral keypair. The used keypair is removed from local state after a successful publish. Run `sync-keys` to replenish the pool when it runs low.

tests/client.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -737,10 +737,11 @@ def cmd_send(args: argparse.Namespace) -> bool:
737737
return False
738738

739739
logger.info(
740-
"platform=%s | shortcode=%s | cat_id=%s",
740+
"platform=%s | shortcode=%s | cat_id=%s | key_id=%d",
741741
platform_info["name"],
742742
platform_info["shortcode"],
743743
platform_info["cat_id"],
744+
kid_index,
744745
)
745746

746747
try:
@@ -753,7 +754,7 @@ def cmd_send(args: argparse.Namespace) -> bool:
753754
attachment=None,
754755
).serialize()
755756
except Exception as e:
756-
logger.error("V1ContentsContainer.serialize failed: %s", e)
757+
logger.exception("V1ContentsContainer.serialize failed: %s", e)
757758
return False
758759

759760
try:
@@ -765,7 +766,7 @@ def cmd_send(args: argparse.Namespace) -> bool:
765766
plaintext=content_bytes,
766767
)
767768
except Exception as e:
768-
logger.error("v1_platform_publisher_encrypt failed: %s", e)
769+
logger.exception("v1_platform_publisher_encrypt failed: %s", e)
769770
return False
770771

771772
token_id_bytes = b64d(token_data["token_id"])
@@ -778,13 +779,13 @@ def cmd_send(args: argparse.Namespace) -> bool:
778779
len_att=0,
779780
t_id=t_id_int,
780781
sess_id=None,
781-
).serialize()
782+
).serialize_without_attachment()
782783
except Exception as e:
783-
logger.error("V1Payloads.serialize failed: %s", e)
784+
logger.exception("V1Payloads.serialize_without_attachment failed: %s", e)
784785
return False
785786

786787
url = f"{args.rest_api}/v1/publications"
787-
body = {"address": args.address, "text": b64(payload_bytes)}
788+
body = {"address": args.address, "text": b64(payload_bytes, urlsafe=False)}
788789

789790
if args.dry_run:
790791
print("--- dry run: would POST to", url)

tests/helpers.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,12 @@ def db_set(key: str, value) -> None:
4848
# ---------------------------------------------------------------------------
4949

5050

51-
def b64(data: bytes, *, truncate: int = 0) -> str:
52-
encoded = base64.urlsafe_b64encode(data).decode()
51+
def b64(data: bytes, *, urlsafe: bool = True, truncate: int = 0) -> str:
52+
encoded = (
53+
base64.urlsafe_b64encode(data).decode()
54+
if urlsafe
55+
else base64.b64encode(data).decode()
56+
)
5357
return encoded[:truncate] + "..." if truncate else encoded
5458

5559

0 commit comments

Comments
 (0)