Skip to content

Commit

Permalink
configurable session cookie path
Browse files Browse the repository at this point in the history
closes #239
  • Loading branch information
aogier committed Jun 23, 2024
1 parent 6eaf16e commit 082e18d
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 18 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,4 @@ dmypy.json

# End of https://www.gitignore.io/api/python,eclipse
.env
.mise.toml
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2024-06-23

### Added

- configurable session cookie path - [#239](https://github.com/aogier/starlette-authlib/issues/239)

## [0.1.40] - 2024-04-14

### Changed
Expand Down Expand Up @@ -287,7 +293,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Correctly implemented ES* and RS* algorithms.

[Unreleased]: https://github.com/aogier/starlette-authlib/compare/0.1.40...HEAD
[Unreleased]: https://github.com/aogier/starlette-authlib/compare/0.2.0...HEAD
[0.2.0]: https://github.com/aogier/starlette-authlib/compare/0.1.40...0.2.0
[0.1.40]: https://github.com/aogier/starlette-authlib/compare/0.1.39...0.1.40
[0.1.39]: https://github.com/aogier/starlette-authlib/compare/0.1.38...0.1.39
[0.1.38]: https://github.com/aogier/starlette-authlib/compare/0.1.37...0.1.38
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "starlette-authlib"
version = "0.1.40"
version = "0.2.0"
description = "A drop-in replacement for Starlette session middleware, using authlib's jwt"
authors = ["Alessandro Ogier <alessandro.ogier@gmail.com>"]
readme = "README.md"
Expand Down
38 changes: 22 additions & 16 deletions starlette_authlib/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
@author: Alessandro Ogier <alessandro.ogier@gmail.com>
"""

from __future__ import annotations

import sys
import time
import typing
Expand Down Expand Up @@ -39,7 +42,8 @@ def __init__(
app: ASGIApp,
secret_key: typing.Union[str, Secret, SecretKey],
session_cookie: str = "session",
max_age: int = 14 * 24 * 60 * 60, # 14 days, in seconds
max_age: int | None = 14 * 24 * 60 * 60, # 14 days, in seconds
path: str = "/",
same_site: str = "lax",
https_only: bool = False,
domain: typing.Optional[str] = config("DOMAIN", cast=str, default=None),
Expand All @@ -64,12 +68,14 @@ def __init__(
),
), "wrong crypto setup"

self.domain = domain
self.session_cookie = session_cookie
self.max_age = max_age
self.path = path
self.security_flags = "httponly; samesite=" + same_site
if https_only: # Secure flag can be used with HTTPS only
self.security_flags += "; secure"
if domain is not None:
self.security_flags += f"; domain={domain}"

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] not in ("http", "websocket"): # pragma: no cover
Expand Down Expand Up @@ -108,31 +114,31 @@ async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
if scope["session"]:
if "exp" not in scope["session"]:
scope["session"]["exp"] = int(time.time()) + self.max_age
scope["session"]["exp"] = (
int(time.time()) + self.max_age if self.max_age else 0
)
data = jwt.encode(
self.jwt_header, scope["session"], str(self.jwt_secret.encode)
)

headers = MutableHeaders(scope=message)
header_value = "%s=%s; path=/; Max-Age=%d; %s" % (
self.session_cookie,
data.decode("utf-8"),
self.max_age,
self.security_flags,
header_value = "{session_cookie}={data}; path={path}; {max_age}{security_flags}".format( # noqa E501
session_cookie=self.session_cookie,
data=data.decode("utf-8"),
path=self.path,
max_age=f"Max-Age={self.max_age}; " if self.max_age else "",
security_flags=self.security_flags,
)
if self.domain: # pragma: no cover
header_value += f"; domain={self.domain}"
headers.append("Set-Cookie", header_value)
elif not initial_session_was_empty:
# The session has been cleared.
headers = MutableHeaders(scope=message)
header_value = "%s=%s; %s" % (
self.session_cookie,
"null; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;",
self.security_flags,
header_value = "{session_cookie}=null; path={path}; {expires}{security_flags}".format( # noqa E501
session_cookie=self.session_cookie,
path=self.path,
expires="expires=Thu, 01 Jan 1970 00:00:00 GMT; ",
security_flags=self.security_flags,
)
if self.domain: # pragma: no cover
header_value += f"; domain={self.domain}"
headers.append("Set-Cookie", header_value)
await send(message)

Expand Down
81 changes: 81 additions & 0 deletions tests/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import pytest
from starlette.applications import Starlette
from starlette.datastructures import Secret
from starlette.middleware import Middleware
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from starlette.testclient import TestClient

from starlette_authlib.middleware import (
Expand Down Expand Up @@ -239,3 +241,82 @@ def test_secure_session():

response = secure_client.get("/view_session")
assert response.json() == {"session": {}}


def test_session_cookie_subpath():
for jwt_alg, secret_key in (
("HS256", "example"),
(
"RS256",
SecretKey(
Secret(open(os.path.join(KEYS_DIR, "rsa.key")).read()),
Secret(open(os.path.join(KEYS_DIR, "rsa.pub")).read()),
),
),
):
second_app = Starlette(
routes=[
Route(
"/update_session",
endpoint=update_session,
methods=["POST"],
),
],
middleware=[
Middleware(
SessionMiddleware,
jwt_alg=jwt_alg,
secret_key=secret_key,
path="/second_app",
)
],
)

app = Starlette(routes=[Mount("/second_app", app=second_app)])
client = TestClient(app, base_url="https://testserver")
response = client.post("/second_app/update_session", json={"some": "data"})
assert response.status_code == 200
cookie = response.headers["set-cookie"]
cookie_path_match = re.search(r"; path=(\S+);", cookie)
assert cookie_path_match is not None
cookie_path = cookie_path_match.groups()[0]
assert cookie_path == "/second_app"


def test_domain_cookie() -> None:
for jwt_alg, secret_key in (
("HS256", "example"),
(
"RS256",
SecretKey(
Secret(open(os.path.join(KEYS_DIR, "rsa.key")).read()),
Secret(open(os.path.join(KEYS_DIR, "rsa.pub")).read()),
),
),
):
app = Starlette(
routes=[
Route("/view_session", endpoint=view_session),
Route("/update_session", endpoint=update_session, methods=["POST"]),
],
middleware=[
Middleware(
SessionMiddleware,
jwt_alg=jwt_alg,
secret_key=secret_key,
domain=".example.com",
)
],
)
client = TestClient(app, base_url="https://testserver")

response = client.post("/update_session", json={"some": "data"})
assert response.json() == {"session": {"some": "data"}}

# check cookie max-age
set_cookie = response.headers["set-cookie"]
assert "domain=.example.com" in set_cookie

client.cookies.delete("session")
response = client.get("/view_session")
assert response.json() == {"session": {}}

0 comments on commit 082e18d

Please sign in to comment.