Skip to content

Commit a7f8561

Browse files
committed
feat(grpc): add v3 RevokeOAuth2Token
1 parent 7210673 commit a7f8561

15 files changed

Lines changed: 666 additions & 348 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ dmypy.json
152152
*.sqlite
153153
*.sqlite3
154154
*.db
155+
*.db-wal
156+
*.db-shm
155157

156158
# Ignore all .proto files
157159
*.proto

db.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Generator, Optional
77
from urllib.parse import quote, quote_plus
88

9-
from sqlalchemy import create_engine, text
9+
from sqlalchemy import create_engine, event, text
1010
from sqlalchemy.engine import Engine
1111
from sqlalchemy.orm import Session, declarative_base, sessionmaker
1212
from sqlalchemy.pool import QueuePool, StaticPool
@@ -33,7 +33,7 @@ def _make_sqlcipher3_creator(db_path: str, key: str):
3333
import sqlcipher3
3434

3535
def connect():
36-
conn = sqlcipher3.connect(db_path, check_same_thread=False)
36+
conn = sqlcipher3.connect(db_path, check_same_thread=False, timeout=30)
3737
conn.execute("PRAGMA cipher_compatibility = 4;")
3838
conn.execute(f"PRAGMA key = \"x'{key}'\";")
3939

@@ -179,12 +179,19 @@ def _create_engine() -> Engine:
179179
engine = create_engine(
180180
url,
181181
echo=False,
182-
connect_args={"check_same_thread": False},
182+
connect_args={"check_same_thread": False, "timeout": 30},
183183
poolclass=QueuePool,
184184
pool_size=5,
185185
pool_pre_ping=True,
186186
)
187187

188+
@event.listens_for(engine, "connect")
189+
def set_sqlite_pragma(dbapi_connection, connection_record):
190+
cursor = dbapi_connection.cursor()
191+
cursor.execute("PRAGMA journal_mode=WAL")
192+
cursor.execute("PRAGMA synchronous=NORMAL")
193+
cursor.close()
194+
188195
with engine.connect() as conn:
189196
conn.execute(text("SELECT 1"))
190197

docs/grpc.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- [Version 3 API](#version-3-api)
88
- [v3: Get OAuth2 Authorization URL](#v3-get-oauth2-authorization-url)
99
- [v3: Exchange OAuth2 Code and Store Token](#v3-exchange-oauth2-code-and-store-token)
10+
- [v3: Revoke and Delete OAuth2 Token](#v3-revoke-and-delete-oauth2-token)
1011
- [Version 2 API](#version-2-api)
1112
- [v2: Get OAuth2 Authorization URL](#v2-get-oauth2-authorization-url)
1213
- [v2: Exchange OAuth2 Code and Store Token](#v2-exchange-oauth2-code-and-store-token)
@@ -222,6 +223,43 @@ EOF
222223

223224
---
224225

226+
### v3: Revoke and Delete OAuth2 Token
227+
228+
Revokes and deletes an OAuth2 token from the vault using the token ID and the key ID used for its encryption.
229+
230+
**Request:** `RevokeOAuth2TokenRequest`
231+
232+
| Field | Type | Required | Description |
233+
| :--- | :--- | :--- | :--- |
234+
| token_id | bytes | Yes | Unique identifier for the stored token |
235+
| key_id | uint32 | Yes | Key index used for encrypting the token |
236+
237+
**Response:** `RevokeOAuth2TokenResponse`
238+
239+
| Field | Type | Description |
240+
| :--- | :--- | :--- |
241+
| success | bool | Operation success |
242+
| message | string | Response message |
243+
244+
**Example:**
245+
246+
```bash
247+
grpcurl -plaintext \
248+
-H 'x-payload-bin: <encrypted_payload_binary>' \
249+
-H 'x-public-key-bin: <client_pk_binary>' \
250+
-H 'x-key-id: <key_id>' \
251+
-H 'x-nonce-bin: <nonce_binary>' \
252+
-H 'x-timestamp: <timestamp>' \
253+
-d '{
254+
"token_id": "<token_id_binary>",
255+
"key_id": 123
256+
}' \
257+
-proto protos/v3/publisher.proto \
258+
<your_host>:<your_port> publisher.v3.Publisher/RevokeOAuth2Token
259+
```
260+
261+
---
262+
225263
## Version 2 API
226264

227265
**Package:** `publisher.v2`

grpc_services/v3/exchange_oauth2_code.py

Lines changed: 15 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
"""ExchangeOAuth2CodeAndStore gRPC service handler."""
33

44
import grpc
5-
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey
65

76
from db import get_session
8-
from grpc_services.v3.utils import create_token_pools_and_encrypt, get_oauth2_adapter
7+
from grpc_services.v3.utils import (
8+
create_token_pools_and_encrypt,
9+
get_oauth2_adapter,
10+
validate_client_ephemeral_public_keys,
11+
)
912
from logutils import get_logger
1013
from models.token import create as create_token
1114
from platforms.adapter_ipc_handler import AdapterIPCHandler
@@ -14,27 +17,6 @@
1417
logger = get_logger(__name__)
1518

1619

17-
def _validate_client_ephemeral_public_keys(keys) -> None:
18-
"""Validate client ephemeral public keys format and count."""
19-
if len(keys) != 256:
20-
raise ValueError(
21-
f"client_ephemeral_public_keys must contain exactly 256 keys, "
22-
f"got {len(keys)}"
23-
)
24-
for key_obj in keys:
25-
if len(key_obj.public_key) != 32:
26-
raise ValueError(
27-
f"Invalid key for key_id {key_obj.key_id}: "
28-
f"must be 32 bytes, got {len(key_obj.public_key)}"
29-
)
30-
try:
31-
X25519PublicKey.from_public_bytes(key_obj.public_key)
32-
except Exception as e:
33-
raise ValueError(
34-
f"Invalid cryptographic key for key_id {key_obj.key_id}: {e}"
35-
)
36-
37-
3820
def ExchangeOAuth2CodeAndStore(self, request, context):
3921
"""Exchange an OAuth2 authorization code for a token."""
4022

@@ -54,16 +36,17 @@ def ExchangeOAuth2CodeAndStore(self, request, context):
5436
return invalid
5537

5638
try:
57-
_validate_client_ephemeral_public_keys(request.client_ephemeral_public_keys)
58-
except ValueError as exc:
59-
return self.handle_create_grpc_error_response(
60-
context,
61-
response,
62-
exc,
63-
grpc.StatusCode.INVALID_ARGUMENT,
39+
validation_error = validate_client_ephemeral_public_keys(
40+
request.client_ephemeral_public_keys
6441
)
42+
if validation_error:
43+
return self.handle_create_grpc_error_response(
44+
context,
45+
response,
46+
validation_error,
47+
grpc.StatusCode.INVALID_ARGUMENT,
48+
)
6549

66-
try:
6750
adapter = get_oauth2_adapter(request.platform)
6851

6952
pipe = AdapterIPCHandler.invoke(
@@ -100,7 +83,7 @@ def ExchangeOAuth2CodeAndStore(self, request, context):
10083
session=s,
10184
)
10285

103-
(token_ciphertext, kid_index, server_public_keys) = (
86+
token_ciphertext, kid_index, server_public_keys = (
10487
create_token_pools_and_encrypt(
10588
token_id=token.id,
10689
client_ephemeral_public_keys=request.client_ephemeral_public_keys,

grpc_services/v3/get_oauth2_auth_url.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,18 @@ def GetOAuth2AuthorizationUrl(self, request, context):
2929
try:
3030
adapter = get_oauth2_adapter(request.platform)
3131

32-
params = {
33-
"state": request.state or None,
34-
"code_verifier": request.code_verifier or None,
35-
"autogenerate_code_verifier": request.autogenerate_code_verifier,
36-
"redirect_url": request.redirect_url or None,
37-
"request_identifier": request.request_identifier or None,
38-
"base_path": adapter["assets_path"],
39-
}
40-
4132
pipe = AdapterIPCHandler.invoke(
4233
adapter_path=adapter["path"],
4334
venv_path=adapter["venv_path"],
4435
method="get_authorization_url",
45-
params=params,
36+
params={
37+
"state": request.state or None,
38+
"code_verifier": request.code_verifier or None,
39+
"autogenerate_code_verifier": request.autogenerate_code_verifier,
40+
"redirect_url": request.redirect_url or None,
41+
"request_identifier": request.request_identifier or None,
42+
"base_path": adapter["assets_path"],
43+
},
4644
)
4745

4846
if pipe.get("error"):
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# SPDX-License-Identifier: GPL-3.0-only
2+
"""RevokeOAuth2Token gRPC service handler."""
3+
4+
import hashlib
5+
import secrets
6+
7+
import grpc
8+
9+
from db import get_session
10+
from grpc_services.v3.utils import get_keys_for_decryption, get_oauth2_adapter
11+
from lib_relaysms_payload_specs.generated import relaysms_spec_payload as rrs
12+
from logutils import get_logger
13+
from models.server_identity_key import mark_key_used as mark_ss_key_used
14+
from platforms.adapter_ipc_handler import AdapterIPCHandler
15+
from protos.v3 import publisher_pb2
16+
17+
logger = get_logger(__name__)
18+
19+
20+
def RevokeOAuth2Token(self, request, context):
21+
"""Revoke and delete a stored OAuth2 token."""
22+
response = publisher_pb2.RevokeOAuth2TokenResponse
23+
24+
payload_bin, auth_error = self.handle_v1_request_auth(context, response)
25+
if auth_error:
26+
return auth_error
27+
28+
invalid = self.handle_request_field_validation(
29+
context, request, response, ["token_id", "key_id"]
30+
)
31+
if invalid:
32+
return invalid
33+
34+
try:
35+
with get_session() as s:
36+
token, token_hash_obj, ss_kid, es_kid, ec_kid_pk = get_keys_for_decryption(
37+
token_id_bytes=request.token_id,
38+
key_id=request.key_id,
39+
session=s,
40+
)
41+
42+
try:
43+
decrypted_token = rrs.v1_token_decrypt_server(
44+
ss_kid=ss_kid,
45+
es_kid=es_kid,
46+
ec_kid_pk=ec_kid_pk,
47+
key_id=request.key_id,
48+
ciphertext=payload_bin,
49+
)
50+
except rrs.V1CryptographicError.FailedToDecrypt as e:
51+
return self.handle_create_grpc_error_response(
52+
context,
53+
response,
54+
f"token decryption failed: {e}",
55+
grpc.StatusCode.UNAUTHENTICATED,
56+
)
57+
58+
if not secrets.compare_digest(
59+
hashlib.sha256(decrypted_token).digest(), token_hash_obj.token_hash
60+
):
61+
return self.handle_create_grpc_error_response(
62+
context,
63+
response,
64+
"token hash mismatch",
65+
grpc.StatusCode.UNAUTHENTICATED,
66+
)
67+
68+
adapter = get_oauth2_adapter(token.platform)
69+
70+
pipe = AdapterIPCHandler.invoke(
71+
adapter_path=adapter["path"],
72+
venv_path=adapter["venv_path"],
73+
method="revoke_token",
74+
params={
75+
"token": token.token_data["token"],
76+
"base_path": adapter["assets_path"],
77+
},
78+
)
79+
80+
if pipe.get("error"):
81+
logger.error("adapter revocation failed: %s", pipe["error"])
82+
83+
s.delete(token)
84+
mark_ss_key_used(request.key_id, s)
85+
86+
return response(success=True, message="Successfully revoked and deleted token")
87+
88+
except ValueError as exc:
89+
return self.handle_create_grpc_error_response(
90+
context, response, exc, grpc.StatusCode.NOT_FOUND
91+
)
92+
93+
except NotImplementedError as exc:
94+
return self.handle_create_grpc_error_response(
95+
context, response, exc, grpc.StatusCode.UNIMPLEMENTED
96+
)
97+
98+
except Exception as exc:
99+
return self.handle_create_grpc_error_response(
100+
context,
101+
response,
102+
exc,
103+
grpc.StatusCode.INTERNAL,
104+
user_msg="Oops! Something went wrong. Please try again later.",
105+
error_type="UNKNOWN",
106+
)

grpc_services/v3/service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from grpc_services.v3.exchange_oauth2_code import ExchangeOAuth2CodeAndStore
1111
from grpc_services.v3.get_oauth2_auth_url import GetOAuth2AuthorizationUrl
12+
from grpc_services.v3.revoke_oauth2_token import RevokeOAuth2Token
1213
from grpc_services.v3.utils import verify_v1_request
1314
from logutils import get_logger
1415
from protos.v3 import publisher_pb2_grpc
@@ -27,6 +28,7 @@ class PublisherServiceV3(publisher_pb2_grpc.PublisherServicer):
2728

2829
GetOAuth2AuthorizationUrl = GetOAuth2AuthorizationUrl
2930
ExchangeOAuth2CodeAndStore = ExchangeOAuth2CodeAndStore
31+
RevokeOAuth2Token = RevokeOAuth2Token
3032

3133
@classmethod
3234
def _get_nonce_lock(cls) -> threading.Lock:

0 commit comments

Comments
 (0)