Skip to content

Commit 80f1d74

Browse files
committed
[lichess] Start working on the Lichess integration
It will be a long run 😅, but hey that's the very first stone now laid. With this 1st commit we add an ugly-but-functional Oauth2 flow that allows a user to get an API token from Lichess.
1 parent e89d1d4 commit 80f1d74

File tree

13 files changed

+451
-81
lines changed

13 files changed

+451
-81
lines changed

.env.dist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
# This file will never be used in production, so it's ok to commit this secret key :-)
22
SECRET_KEY=not-a-security-issue
33
DATABASE_URL=sqlite:///db.sqlite3
4+
5+
LICHESS_CLIENT_ID=zakuchess-local-dev

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ readme = "README.md"
1111
requires-python = ">=3.11"
1212

1313
dependencies= [
14+
# Django doesn't follow SemVer, so we need to specify the minor version:
1415
"Django==5.1.*",
15-
# Django doesn't follow SemVer, so we need to specify the minor version
1616
"gunicorn==22.*",
1717
"django-alive==1.*",
1818
"chess==1.*",
@@ -22,9 +22,11 @@ dependencies= [
2222
"requests==2.*",
2323
"django-axes[ipware]==6.*",
2424
"whitenoise==6.*",
25-
"django-import-export==3.*",
25+
"django-import-export==4.*",
2626
"msgspec==0.18.*",
2727
"zakuchess",
28+
"authlib==1.*",
29+
"berserk>=0.13.2",
2830
]
2931

3032

src/apps/lichess_bridge/__init__.py

Whitespace-only changes.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Packages such as django-allauth or AuthLib do provide turnkey Django integrations
2+
# for OAuth2 - or even with Lichess specifically, for the former.
3+
# However, in my case I don't want to use Django's "auth" machinery to manage Lichess
4+
# users: all I want is to store in an HTTP-only cookie that they have attached
5+
# a Lichess account, alongside with the token we'll need to communicate with Lichess.
6+
# Hence, the following low level code, written after the Flask example given by Lichess:
7+
# https://github.com/lakinwecker/lichess-oauth-flask/blob/master/app.py
8+
# Authlib "vanilla Python" usage:
9+
# https://docs.authlib.org/en/latest/client/oauth2.html
10+
11+
import functools
12+
from typing import TYPE_CHECKING, Literal
13+
14+
import msgspec
15+
from authlib.common.security import generate_token
16+
from authlib.integrations.requests_client import OAuth2Session
17+
from django.conf import settings
18+
from django.urls import reverse
19+
20+
if TYPE_CHECKING:
21+
from typing import Self
22+
23+
LICHESS_OAUTH2_SCOPES = ("board:play",)
24+
25+
26+
class LichessTokenRetrievalProcessContext(
27+
msgspec.Struct,
28+
kw_only=True, # type: ignore[call-arg]
29+
):
30+
"""
31+
Short-lived data required to complete the retrieval of an API token
32+
from Lichess' OAuth2 process.
33+
"""
34+
35+
csrf_state: str
36+
code_verifier: str
37+
zakuchess_redirect_url: str # an absolute HTTP/HTTPS URL
38+
39+
def to_cookie_content(self) -> str:
40+
cookie_content = {
41+
# We don't encode the redirect URL into the cookie, so let's customise
42+
# what we need by encoding a dict, rather than "self"
43+
"csrf": self.csrf_state,
44+
"verif": self.code_verifier,
45+
}
46+
return msgspec.json.encode(cookie_content).decode()
47+
48+
@classmethod
49+
def from_cookie_content(
50+
cls,
51+
cookie_content: str,
52+
*,
53+
zakuchess_hostname: str,
54+
zakuchess_protocol: str = "https",
55+
) -> "Self":
56+
cookie_content_dict = msgspec.json.decode(cookie_content)
57+
redirect_uri = _get_lichess_oauth2_zakuchess_redirect_uri(
58+
zakuchess_protocol,
59+
zakuchess_hostname,
60+
)
61+
62+
return cls(
63+
csrf_state=cookie_content_dict["csrf"],
64+
code_verifier=cookie_content_dict["verif"],
65+
zakuchess_redirect_url=redirect_uri,
66+
)
67+
68+
@classmethod
69+
def create_afresh(
70+
cls,
71+
*,
72+
zakuchess_hostname: str,
73+
zakuchess_protocol: str = "https",
74+
) -> "Self":
75+
"""
76+
Returns a context with randomly generated "CSRF state" and "code verifier".
77+
"""
78+
redirect_uri = _get_lichess_oauth2_zakuchess_redirect_uri(
79+
zakuchess_protocol, zakuchess_hostname
80+
)
81+
82+
csrf_state = generate_token()
83+
code_verifier = generate_token(48)
84+
85+
return cls(
86+
csrf_state=csrf_state,
87+
code_verifier=code_verifier,
88+
zakuchess_redirect_url=redirect_uri,
89+
)
90+
91+
92+
class LichessToken(msgspec.Struct):
93+
token_type: Literal["Bearer"]
94+
access_token: str
95+
expires_in: int # number of seconds
96+
expires_at: int # a Unix timestamp
97+
98+
99+
def get_lichess_token_retrieval_via_oauth2_process_starting_url(
100+
*,
101+
context: LichessTokenRetrievalProcessContext,
102+
) -> str:
103+
lichess_authorization_endpoint = f"{settings.LICHESS_HOST}/oauth"
104+
105+
client = _get_lichess_client()
106+
uri, state = client.create_authorization_url(
107+
lichess_authorization_endpoint,
108+
response_type="code",
109+
state=context.csrf_state,
110+
redirect_uri=context.zakuchess_redirect_url,
111+
code_verifier=context.code_verifier,
112+
)
113+
assert state == context.csrf_state
114+
115+
return uri
116+
117+
118+
def extract_lichess_token_from_oauth2_callback_url(
119+
*,
120+
authorization_callback_response_url: str,
121+
context: LichessTokenRetrievalProcessContext,
122+
) -> LichessToken:
123+
lichess_token_endpoint = f"{settings.LICHESS_HOST}/api/token"
124+
125+
client = _get_lichess_client()
126+
token_as_dict = client.fetch_token(
127+
lichess_token_endpoint,
128+
authorization_response=authorization_callback_response_url,
129+
redirect_uri=context.zakuchess_redirect_url,
130+
code_verifier=context.code_verifier,
131+
)
132+
133+
return LichessToken(
134+
**token_as_dict,
135+
)
136+
137+
138+
@functools.lru_cache
139+
def _get_lichess_oauth2_zakuchess_redirect_uri(
140+
zakuchess_protocol: str, zakuchess_hostname: str
141+
) -> str:
142+
return f"{zakuchess_protocol}://{zakuchess_hostname}" + reverse(
143+
"lichess_bridge:oauth2_token_callback"
144+
)
145+
146+
147+
def _get_lichess_client() -> OAuth2Session:
148+
return OAuth2Session(
149+
client_id=settings.LICHESS_CLIENT_ID,
150+
code_challenge_method="S256",
151+
scope=" ".join(LICHESS_OAUTH2_SCOPES),
152+
)

src/apps/lichess_bridge/components/__init__.py

Whitespace-only changes.

src/apps/lichess_bridge/components/pages/__init__.py

Whitespace-only changes.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from typing import TYPE_CHECKING
2+
3+
from dominate.tags import section
4+
from dominate.util import raw
5+
6+
from apps.lichess_bridge.authentication import (
7+
get_lichess_token_retrieval_via_oauth2_process_starting_url,
8+
)
9+
from apps.webui.components.layout import page
10+
11+
if TYPE_CHECKING:
12+
from django.http import HttpRequest
13+
14+
from apps.lichess_bridge.authentication import (
15+
LichessTokenRetrievalProcessContext,
16+
)
17+
18+
19+
def lichess_no_account_linked_page(
20+
*,
21+
request: "HttpRequest",
22+
lichess_oauth2_process_context: "LichessTokenRetrievalProcessContext",
23+
) -> str:
24+
target_url = get_lichess_token_retrieval_via_oauth2_process_starting_url(
25+
context=lichess_oauth2_process_context
26+
)
27+
28+
return page(
29+
section(
30+
raw(f"""Click here: <a href="{target_url}">{target_url}</a>"""),
31+
cls="text-slate-50",
32+
),
33+
request=request,
34+
title="Lichess - no account linked",
35+
)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import logging
2+
from typing import TYPE_CHECKING
3+
4+
from msgspec import MsgspecError
5+
6+
from .authentication import LichessTokenRetrievalProcessContext
7+
8+
if TYPE_CHECKING:
9+
from django.http import HttpRequest, HttpResponse
10+
11+
_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_NAME = "lichess.oauth2.ctx"
12+
# One day should be more than enough to let the user grant their authorisation:
13+
_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_MAX_AGE = 3600 * 24
14+
15+
16+
_logger = logging.getLogger(__name__)
17+
18+
19+
def store_oauth2_token_retrieval_context_in_response_cookie(
20+
*, context: LichessTokenRetrievalProcessContext, response: "HttpResponse"
21+
) -> None:
22+
"""
23+
Store OAuth2 token retrieval context into a response cookie.
24+
"""
25+
26+
response.set_cookie(
27+
_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_NAME,
28+
context.to_cookie_content(),
29+
max_age=_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_MAX_AGE,
30+
httponly=True,
31+
)
32+
33+
34+
def get_oauth2_token_retrieval_context_from_request(
35+
request: "HttpRequest",
36+
) -> LichessTokenRetrievalProcessContext | None:
37+
"""
38+
Returns a context created from the "CSRF state" and "code verifier" found in the request's cookie.
39+
"""
40+
cookie_content: str | None = request.COOKIES.get(
41+
_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_NAME
42+
)
43+
if not cookie_content:
44+
return None
45+
46+
try:
47+
context = LichessTokenRetrievalProcessContext.from_cookie_content(
48+
cookie_content,
49+
zakuchess_hostname=request.get_host(),
50+
zakuchess_protocol=request.scheme,
51+
)
52+
return context
53+
except MsgspecError:
54+
_logger.exception("Could not decode cookie content.")
55+
return None
56+
57+
58+
def delete_oauth2_token_retrieval_context_from_cookies(
59+
response: "HttpResponse",
60+
) -> None:
61+
response.delete_cookie(_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_NAME)

src/apps/lichess_bridge/urls.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from django.urls import path
2+
3+
from . import views
4+
5+
app_name = "lichess_bridge"
6+
7+
urlpatterns = [
8+
path("", views.lichess_home, name="homepage"),
9+
path(
10+
"webhook/oauth2/token-callback/",
11+
views.lichess_webhook_oauth2_token_callback,
12+
name="oauth2_token_callback",
13+
),
14+
]

src/apps/lichess_bridge/views.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from typing import TYPE_CHECKING
2+
3+
from django.http import HttpResponse
4+
from django.shortcuts import redirect
5+
from django.views.decorators.http import require_GET
6+
7+
from .authentication import (
8+
LichessTokenRetrievalProcessContext,
9+
extract_lichess_token_from_oauth2_callback_url,
10+
)
11+
from .components.pages.lichess import lichess_no_account_linked_page
12+
from .cookie_helpers import (
13+
delete_oauth2_token_retrieval_context_from_cookies,
14+
get_oauth2_token_retrieval_context_from_request,
15+
store_oauth2_token_retrieval_context_in_response_cookie,
16+
)
17+
18+
if TYPE_CHECKING:
19+
from django.http import HttpRequest
20+
21+
22+
def lichess_home(request: "HttpRequest") -> HttpResponse:
23+
lichess_oauth2_process_context = LichessTokenRetrievalProcessContext.create_afresh(
24+
zakuchess_hostname=request.get_host(),
25+
zakuchess_protocol=request.scheme,
26+
)
27+
28+
response = HttpResponse(
29+
lichess_no_account_linked_page(
30+
request=request,
31+
lichess_oauth2_process_context=lichess_oauth2_process_context,
32+
)
33+
)
34+
# We will need to re-use some of this context's data in the webhook below:
35+
# --> let's store that in an HTTP-only cookie
36+
store_oauth2_token_retrieval_context_in_response_cookie(
37+
context=lichess_oauth2_process_context, response=response
38+
)
39+
40+
return response
41+
42+
43+
@require_GET
44+
def lichess_webhook_oauth2_token_callback(request: "HttpRequest") -> HttpResponse:
45+
# Retrieve a context from the HTTP-only cookie we created above:
46+
lichess_oauth2_process_context = get_oauth2_token_retrieval_context_from_request(
47+
request
48+
)
49+
if lichess_oauth2_process_context is None:
50+
# TODO: Do something with that error
51+
return redirect("lichess_bridge:homepage")
52+
53+
token = extract_lichess_token_from_oauth2_callback_url(
54+
authorization_callback_response_url=request.get_full_path(),
55+
context=lichess_oauth2_process_context,
56+
)
57+
58+
response = HttpResponse(f"{token=}")
59+
delete_oauth2_token_retrieval_context_from_cookies(response)
60+
61+
return response

0 commit comments

Comments
 (0)