Skip to content

Commit 3e9103d

Browse files
feat(websocket): add websocket authorization support
1 parent db6e5d5 commit 3e9103d

14 files changed

Lines changed: 868 additions & 71 deletions

File tree

README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ If you were familiar with flask-jwt-extended or fastapi-jwt-auth this extension
1919
- Access tokens and refresh tokens
2020
- Freshness Tokens
2121
- Revoking Tokens
22+
- WebSocket authorization
2223
- Support for adding custom claims to Tokens
2324
- Built-in Base64 Encoding of Tokens
2425
- Custom token types
@@ -47,9 +48,6 @@ def get_config():
4748
return {"authpaseto_secret_key": "secret"}
4849
```
4950

50-
## Roadmap
51-
- Support for WebSocket authorization
52-
5351
## FAQ
5452
- **Where's support for tokens in cookies?**\
5553
This project focuses on header-based PASETO authentication and only includes the features required for that workflow.\

docs/api-doc.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ In here you will find the API for everything exposed in this extension.
2020
#
2121
### Protected Endpoint
2222

23-
**paseto_required**(optional: bool = False, fresh: bool = False, refresh_token: bool = False, type: str = access, base64_encoded: bool = False):
23+
**paseto_required**(optional: bool = False, fresh: bool = False, refresh_token: bool = False, type: str = access, base64_encoded: bool = False, location = None, token_key = None, token_prefix = None, token = None):
2424

2525
If you call this function, it will ensure that the requester has a valid access token before
2626
executing the code below your router. Depending on set options, it might not raise an exception even if the check fails.*
@@ -29,10 +29,17 @@ In here you will find the API for everything exposed in this extension.
2929
**optional**: Defines whether the check should continue even if no PASETO is found.\
3030
(An exception will still always be raised if an invalid one is found.)
3131
**fresh**: If set to True, requires any PASETO found to be a fresh access token.
32-
**refresh_token**: If set to True, checks for a refresh token instead of an access token.
33-
**type**: If set to a string, this gets checked against the type of the token provided. Used for custom types other than access or refresh tokens.
34-
**base64_encoded**: Whether the token to check is base64 encoded.
35-
* Returns: None
32+
**refresh_token**: If set to True, checks for a refresh token instead of an access token.
33+
**type**: If set to a string, this gets checked against the type of the token provided. Used for custom types other than access or refresh tokens.
34+
**base64_encoded**: Whether the token to check is base64 encoded.
35+
**location**: Override the configured token location for this endpoint. HTTP supports `headers` and `json`; websocket handlers support `headers` and `query`.
36+
**token_key**: Override the configured header name, JSON key, or websocket query key for this check.
37+
**token_prefix**: Override the configured transport prefix such as `Bearer`.
38+
**token**: Provide a raw token directly and bypass request/websocket transport lookup.
39+
* Returns: None
40+
41+
For websocket handlers, call **paseto_required()** before `accept()`. Auth failures close
42+
the connection with websocket close code `1008` and use the auth error message as the close reason.
3643

3744

3845

docs/configuration/general.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,21 @@
2525
`authpaseto_decode_audience`
2626
: The audience or list of audiences you expect in a PASETO when decoding it. Defaults to `None`
2727

28-
`authpaseto_access_token_expires`
29-
: How long an access token should live before it expires. This takes value `integer` *(seconds)* or
30-
`datetime.timedelta`, and defaults to **15 minutes**. Can be set to `False` to disable expiration.
31-
32-
`authpaseto_refresh_token_expires`
33-
: How long an refresh token should live before it expires. This takes value `integer` *(seconds)* or
34-
`datetime.timedelta`, and defaults to **30 days**. Can be set to `False` to disable expiration.
28+
`authpaseto_access_token_expires`
29+
: How long an access token should live before it expires. This takes value `integer` *(seconds)* or
30+
`datetime.timedelta`, and defaults to **15 minutes**. Can be set to `False` to disable expiration.
31+
32+
`authpaseto_refresh_token_expires`
33+
: How long an refresh token should live before it expires. This takes value `integer` *(seconds)* or
34+
`datetime.timedelta`, and defaults to **30 days**. Can be set to `False` to disable expiration.
35+
36+
`authpaseto_websocket_token_location`
37+
: Where websocket handlers should look for tokens during the handshake. Valid values are
38+
`headers` and `query`. Defaults to `("headers",)`.
39+
40+
`authpaseto_websocket_query_key`
41+
: The query parameter name used when websocket query transport is enabled. Defaults to `token`.
42+
43+
`authpaseto_websocket_query_type`
44+
: Optional prefix to require in the websocket query parameter, similar to `authpaseto_header_type`.
45+
Defaults to `None`.

docs/usage/websocket.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
Create a file `websocket.py`:
2+
3+
```python hl_lines="31 42-46 50-58"
4+
{!../examples/websocket.py!}
5+
```
6+
7+
Websocket endpoints use the same dependency injection pattern as HTTP endpoints:
8+
9+
```python
10+
@app.websocket("/ws/header")
11+
async def websocket_header(
12+
websocket: WebSocket,
13+
Authorize: AuthPASETO = Depends(),
14+
) -> None:
15+
Authorize.paseto_required()
16+
await websocket.accept()
17+
```
18+
19+
The important rule is to call **paseto_required()** before `accept()`. If auth fails,
20+
the handshake is rejected with websocket close code `1008` and the auth error message
21+
is used as the close reason.
22+
23+
## Header Authorization
24+
25+
By default, websocket authorization reads the token from the configured auth header,
26+
reusing `authpaseto_header_name` and `authpaseto_header_type`.
27+
28+
```
29+
Authorization: Bearer <access_token>
30+
```
31+
32+
## Query Authorization
33+
34+
Browsers often cannot attach custom auth headers during the websocket handshake. For
35+
those clients, you can either enable websocket query transport globally:
36+
37+
```python
38+
@AuthPASETO.load_config
39+
def get_config():
40+
return {
41+
"authpaseto_secret_key": "secret",
42+
"authpaseto_websocket_token_location": ["query"],
43+
}
44+
```
45+
46+
or opt into query transport for a single route:
47+
48+
```python
49+
Authorize.paseto_required(location="query")
50+
```
51+
52+
The default query parameter is `token`, and you can customize it with
53+
`authpaseto_websocket_query_key` or per-route with `token_key`.
54+
55+
## Parity and Limits
56+
57+
- `optional=True`, `fresh=True`, `refresh_token=True`, custom `type`, denylist checks,
58+
issuer validation, audience validation, base64 decoding, and `token=` overrides all
59+
work the same way for websocket handlers.
60+
- JSON body token transport is not supported for websocket handshakes, because there is
61+
no request body available before the connection is accepted.

examples/websocket.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket
2+
from fastapi.responses import JSONResponse
3+
from pydantic import BaseModel
4+
5+
from fastapi_paseto import AuthPASETO
6+
from fastapi_paseto.exceptions import AuthPASETOException
7+
8+
app = FastAPI()
9+
10+
11+
class User(BaseModel):
12+
username: str
13+
password: str
14+
15+
16+
@AuthPASETO.load_config
17+
def get_config():
18+
"""Return the application auth configuration."""
19+
20+
return {"authpaseto_secret_key": "secret"}
21+
22+
23+
@app.exception_handler(AuthPASETOException)
24+
def authpaseto_exception_handler(request: Request, exc: AuthPASETOException):
25+
"""Return auth exceptions as JSON for HTTP endpoints."""
26+
27+
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
28+
29+
30+
@app.post("/login")
31+
def login(user: User, Authorize: AuthPASETO = Depends()):
32+
"""Issue an access token for the demo user."""
33+
34+
if user.username != "test" or user.password != "test":
35+
raise HTTPException(status_code=401, detail="Bad username or password")
36+
37+
access_token = Authorize.create_access_token(subject=user.username)
38+
return {"access_token": access_token}
39+
40+
41+
@app.websocket("/ws/header")
42+
async def websocket_header(
43+
websocket: WebSocket,
44+
Authorize: AuthPASETO = Depends(),
45+
) -> None:
46+
"""Authorize the websocket using the configured auth header."""
47+
48+
Authorize.paseto_required()
49+
await websocket.accept()
50+
await websocket.send_json({"user": Authorize.get_subject()})
51+
await websocket.close()
52+
53+
54+
@app.websocket("/ws/query")
55+
async def websocket_query(
56+
websocket: WebSocket,
57+
Authorize: AuthPASETO = Depends(),
58+
) -> None:
59+
"""Authorize the websocket using a query parameter fallback."""
60+
61+
Authorize.paseto_required(location="query")
62+
await websocket.accept()
63+
await websocket.send_json({"user": Authorize.get_subject()})
64+
await websocket.close()

mkdocs.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ markdown_extensions:
3131

3232
nav:
3333
- About: index.md
34-
- Usage:
35-
- Basic Usage: usage/basic.md
36-
- Partially Protecting: usage/optional.md
34+
- Usage:
35+
- Basic Usage: usage/basic.md
36+
- WebSocket Usage: usage/websocket.md
37+
- Partially Protecting: usage/optional.md
3738
- Refresh Tokens: usage/refresh.md
3839
- Freshness Tokens: usage/freshness.md
3940
- Revoking Tokens: usage/revoking.md

0 commit comments

Comments
 (0)