|
1 | 1 | import json |
2 | 2 | from collections.abc import Callable |
3 | | -from datetime import datetime, timedelta |
| 3 | +from datetime import UTC, datetime, timedelta |
4 | 4 | from functools import wraps |
5 | | -from typing import Annotated, Any, Literal, cast |
| 5 | +from typing import Annotated, Any, Literal |
6 | 6 |
|
7 | 7 | import httpx |
8 | 8 | from fastapi import Depends, HTTPException, Request, status |
9 | | -from jose import jwe |
10 | | -from jose.exceptions import JWEError |
| 9 | +from jwcrypto import jwe, jwk |
| 10 | +from jwcrypto.common import JWException |
11 | 11 | from pydantic import BaseModel |
12 | 12 |
|
13 | 13 | from src.lib.config import settings |
@@ -65,59 +65,64 @@ class CurrentUserInfo(BaseModel): |
65 | 65 | email_verified: bool = False |
66 | 66 |
|
67 | 67 |
|
| 68 | +def _get_jwe_key() -> jwk.JWK: |
| 69 | + """Get JWK key for JWE encryption/decryption.""" |
| 70 | + key_bytes = settings.JWE_SECRET_KEY.encode("utf-8") |
| 71 | + if len(key_bytes) < 32: |
| 72 | + key_bytes = key_bytes.ljust(32, b"\0") |
| 73 | + elif len(key_bytes) > 32: |
| 74 | + key_bytes = key_bytes[:32] |
| 75 | + return jwk.JWK(kty="oct", k=jwk.base64url_encode(key_bytes)) |
| 76 | + |
| 77 | + |
68 | 78 | def create_access_token(user_id: str) -> str: |
69 | 79 | """Create JWE access token.""" |
70 | | - now = datetime.utcnow() |
| 80 | + now = datetime.now(UTC) |
71 | 81 | payload = { |
72 | 82 | "user_id": user_id, |
73 | 83 | "token_type": "access", |
74 | 84 | "exp": int((now + timedelta(hours=1)).timestamp()), |
75 | 85 | "iat": int(now.timestamp()), |
76 | 86 | } |
77 | | - encrypted = jwe.encrypt( |
78 | | - json.dumps(payload).encode(), |
79 | | - settings.JWE_SECRET_KEY, |
80 | | - algorithm="A256GCM", |
81 | | - encryption="A256GCM", |
| 87 | + |
| 88 | + key = _get_jwe_key() |
| 89 | + jwe_token = jwe.JWE( |
| 90 | + json.dumps(payload).encode("utf-8"), |
| 91 | + recipient=key, |
| 92 | + protected={"alg": "A256KW", "enc": "A256GCM"}, |
82 | 93 | ) |
83 | | - if isinstance(encrypted, bytes): |
84 | | - return encrypted.decode() |
85 | | - return cast(str, encrypted) |
| 94 | + return str(jwe_token.serialize(compact=True)) |
86 | 95 |
|
87 | 96 |
|
88 | 97 | def create_refresh_token(user_id: str) -> str: |
89 | 98 | """Create JWE refresh token.""" |
90 | | - now = datetime.utcnow() |
| 99 | + now = datetime.now(UTC) |
91 | 100 | payload = { |
92 | 101 | "user_id": user_id, |
93 | 102 | "token_type": "refresh", |
94 | 103 | "exp": int((now + timedelta(days=7)).timestamp()), |
95 | 104 | "iat": int(now.timestamp()), |
96 | 105 | } |
97 | | - encrypted = jwe.encrypt( |
98 | | - json.dumps(payload).encode(), |
99 | | - settings.JWE_SECRET_KEY, |
100 | | - algorithm="A256GCM", |
101 | | - encryption="A256GCM", |
| 106 | + |
| 107 | + key = _get_jwe_key() |
| 108 | + jwe_token = jwe.JWE( |
| 109 | + json.dumps(payload).encode("utf-8"), |
| 110 | + recipient=key, |
| 111 | + protected={"alg": "A256KW", "enc": "A256GCM"}, |
102 | 112 | ) |
103 | | - if isinstance(encrypted, bytes): |
104 | | - return encrypted.decode() |
105 | | - return cast(str, encrypted) |
| 113 | + return str(jwe_token.serialize(compact=True)) |
106 | 114 |
|
107 | 115 |
|
108 | 116 | def decode_token(token: str) -> TokenPayload: |
109 | 117 | """Decode and validate JWE token.""" |
110 | 118 | try: |
111 | | - decrypted = jwe.decrypt(token, settings.JWE_SECRET_KEY) |
112 | | - if decrypted is None: |
113 | | - raise HTTPException( |
114 | | - status_code=status.HTTP_401_UNAUTHORIZED, |
115 | | - detail="Invalid token", |
116 | | - headers={"WWW-Authenticate": "Bearer"}, |
117 | | - ) |
118 | | - payload = json.loads(decrypted.decode()) |
| 119 | + key = _get_jwe_key() |
| 120 | + jwe_token = jwe.JWE() |
| 121 | + jwe_token.deserialize(token) |
| 122 | + jwe_token.decrypt(key) |
| 123 | + payload = json.loads(jwe_token.payload.decode("utf-8")) |
119 | 124 | return TokenPayload(**payload) |
120 | | - except JWEError as e: |
| 125 | + except JWException: |
121 | 126 | raise HTTPException( |
122 | 127 | status_code=status.HTTP_401_UNAUTHORIZED, |
123 | 128 | detail="Invalid token", |
@@ -233,7 +238,7 @@ async def get_current_user(request: Request) -> CurrentUserInfo: |
233 | 238 | detail="Invalid token type", |
234 | 239 | ) |
235 | 240 |
|
236 | | - if datetime.utcnow().timestamp() > payload.exp: |
| 241 | + if datetime.now(UTC).timestamp() > payload.exp: |
237 | 242 | raise HTTPException( |
238 | 243 | status_code=status.HTTP_401_UNAUTHORIZED, |
239 | 244 | detail="Token expired", |
|
0 commit comments