Third-party Authentication
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Tornado 6.5.10 on Python 3.13, checked on 2026-10-07. Tornado 6.6 is only available as an alpha (6.6a1) at the time of writing, so anything described as "6.6" or "6.7" below is upcoming and may still change. Every Python example was executed with those versions; the identity provider is faked by a local server bound to a loopback port, so no real provider, account or network access is involved. The running example is the Bookshelf Live scenario: a reader signs in with a Google account, and the handlers that serve the live shelf recognise the user through a signed cookie.
"Third-party authentication" means letting another service (Google, Facebook and so on) prove who the user is, instead
of storing and checking a password yourself. Tornado ships a small module for it, tornado.auth, made of mixins:
classes that you add to a RequestHandler to give it the methods that talk to one provider. This is much thinner
than the Authlib-based approach described in
Flask: OAuth, Social Login & LDAP: the mixins build the authorization URL and exchange the code for a token,
and nothing else. Sessions, the signed cookie that remembers the user afterwards, user records, token storage and the
protections against login CSRF are your code. The signed-cookie mechanics (cookie_secret, set_signed_cookie,
get_current_user, @authenticated) are the topic of Authentication & Signed Cookies;
this page uses them in the smallest possible form. For the protocol itself (grants, scopes, tokens) see
OAuth 2.0 and OpenID Connect on this site and, specifically,
Authorization Code and PKCE.
The tornado.auth Module
All classes in tornado.auth follow one usage pattern, described by the module documentation: on a login handler
you call authorize_redirect() or authenticate_redirect() to send the browser to the provider, and later
get_authenticated_user() to complete the sign-in; on any other handler you call a provider-specific request method
such as facebook_request() or the generic oauth2_request() with the stored token. The module is built to be
extended: the abstract base mixins read their configuration from class attributes (_OAUTH_AUTHORIZE_URL,
_OAUTH_ACCESS_TOKEN_URL), which is how you add a provider that Tornado does not know.
| Class | Protocol | Status in Tornado 6.5.10 |
|---|---|---|
|
OAuth 2.0 (abstract base) |
Current. |
|
OAuth 2.0 with Google |
Current (since 3.2). Adds |
|
OAuth 2.0 with the Facebook Graph API |
Current. Adds |
|
OAuth 1.0 and 1.0a (abstract base) |
Legacy. No service that needs it still exists in the module except Twitter. |
|
OAuth 1.0a with the Twitter API 1.1 |
Deprecated since 6.3 (the API it targets was retired); it will no longer be updated and will be removed. |
|
OpenID 2.0 and Attribute Exchange |
Documented as deprecated since 6.6 (OpenID 2.0 is no longer widely supported); to be removed in 6.7. The 6.5.10 code
already emits a |
Two remarks help to read this table. First, "OpenID" here is the old OpenID 2.0 protocol, which has nothing to do with
OpenID Connect; tornado.auth has no OpenID Connect support. Second, OAuth2Mixin.authorize_redirect() still accepts a
client_secret argument, but it never had any effect and has been deprecated since 6.4 (removal in 7.0): the
authorization redirect never carries the secret, so do not pass it.
The Authorization-code Flow
The mixins implement the authorization code grant: the browser leaves for the provider, the user consents, and the
provider sends the browser back to your callback URL with a one-time code, which your server then exchanges for an
access token over a direct (server to server) request. In Tornado both the redirect to the provider and the callback
are usually handled by the same handler, branching on whether the request carries a code:
The methods you will call, with the exact signatures of 6.5.10:
| Method | What it does |
|---|---|
|
Synchronous (not awaited since 6.0). Builds the provider’s authorization URL from |
|
A coroutine. POSTs the code to the token URL with |
|
A coroutine. Fetches a URL with the access token appended as an |
|
Returns the |
The redirect_uri you pass must be byte for byte the one registered at the provider, which is why the mixin
documentation advises building it from configuration (a redirect_base_uri setting plus reverse_url) instead of from
self.request.full_url(), which depends on the Host header and on any proxy in front of the server.
Configuration and Secrets
GoogleOAuth2Mixin reads the client credentials from an application setting named google_oauth, a dictionary with
the keys key (the client id) and secret (the client secret). The method get_google_oauth_settings() returns that
dictionary and exists so you can override it to read the credentials from somewhere else, for example a secret store or
a database row per tenant. You create the credentials in the Google Cloud console as an OAuth client ID of type Web
application, and register the exact redirect URI there. While the application is in "testing" mode, Google also
requires the accounts that may sign in to be listed as test users.
The client secret is a credential, so it must never be in the source tree. The example reads everything from the environment and, for local experiments only, generates throwaway values:
import hmac
import os
import secrets
from urllib.parse import urljoin
from tornado import web
from tornado.auth import FacebookGraphMixin, GoogleOAuth2Mixin
def load_settings() -> dict:
"""Credentials come from the environment, never from source control."""
return {
"cookie_secret": os.environ.get("BOOKSHELF_COOKIE_SECRET") or secrets.token_hex(32),
"login_url": "/login/google",
"redirect_base_uri": os.environ.get("BOOKSHELF_BASE_URI", "http://localhost:8888"),
"cookie_secure": os.environ.get("BOOKSHELF_COOKIE_SECURE", "0") == "1",
"google_oauth": {
"key": os.environ.get("GOOGLE_CLIENT_ID", "local-client-id"),
"secret": os.environ.get("GOOGLE_CLIENT_SECRET") or secrets.token_hex(16),
},
}
class BaseHandler(web.RequestHandler):
def get_current_user(self):
user_id = self.get_signed_cookie("user", max_age_days=1)
return user_id.decode() if user_id else None
@property
def cookie_flags(self) -> dict:
return {"httponly": True, "samesite": "Lax", "secure": self.settings["cookie_secure"]}
class HomeHandler(BaseHandler):
@web.authenticated
def get(self):
self.write({"user": self.current_user})
BaseHandler.cookie_flags shows the cookie attributes every login-related cookie should carry: httponly (scripts
cannot read it), samesite="Lax" (the cookie accompanies the top-level navigation back from the provider, but not
cross-site sub-requests) and secure (HTTPS only, switched on through an environment variable so that local HTTP
development keeps working). set_signed_cookie() passes its extra keyword arguments to set_cookie().
Signing in with Google
A complete Google login handler is the mixin plus about thirty lines. Everything outside the three mixin calls
(authorize_redirect, get_authenticated_user, oauth2_request) is the part that the mixin leaves to you, and it is
the part that makes the difference between a working and a safe sign-in:
class GoogleLoginHandler(BaseHandler, GoogleOAuth2Mixin):
async def get(self):
redirect_uri = urljoin(self.settings["redirect_base_uri"], self.reverse_url("google_callback"))
if self.get_argument("error", None):
raise web.HTTPError(403, "provider refused: %s", self.get_argument("error"))
if not self.get_argument("code", None):
# Step 1: remember a random value and send the browser to Google.
state = secrets.token_urlsafe(32)
self.set_signed_cookie("oauth_state", state, expires_days=None, **self.cookie_flags)
self.authorize_redirect(
redirect_uri=redirect_uri,
client_id=self.get_google_oauth_settings()["key"],
scope=["openid", "email", "profile"],
response_type="code",
extra_params={"state": state},
)
return
# Step 2: Google sent the browser back with ?code=...&state=...
expected = self.get_signed_cookie("oauth_state", max_age_days=0.01)
received = self.get_argument("state", "")
self.clear_cookie("oauth_state")
if expected is None or not hmac.compare_digest(expected, received.encode()):
raise web.HTTPError(403, "state mismatch")
tokens = await self.get_authenticated_user(redirect_uri=redirect_uri, code=self.get_argument("code"))
profile = await self.oauth2_request(self._OAUTH_USERINFO_URL, access_token=tokens["access_token"])
# Keep only an identifier in the cookie: a signed cookie is readable by the browser.
self.set_signed_cookie("user", profile["id"], expires_days=1, **self.cookie_flags)
self.redirect("/")
The points worth studying in this handler:
-
The route is registered with
url(r"/login/google", GoogleLoginHandler, name="google_callback"), so the same URL serves both legs andreverse_url("google_callback")gives the path to register as the redirect URI. -
The
scopelist is joined with spaces byauthorize_redirect.openid email profileasks Google for the standard profile claims; the mixin does not request or parse an ID token, so theopenidscope is of no use unless you read theid_tokenfield of the token response yourself (see the last section). -
get_authenticated_userreturns the token response only. The example then callsoauth2_requestagainst_OAUTH_USERINFO_URL, a class attribute ofGoogleOAuth2Mixinthat holds Google’s userinfo endpoint. It is a private-looking name, so a cautious application keeps the URL in its own configuration instead. The user’s stable identifier in that response isid; the email address is a property of the account, not an identity, and may change. -
HTTPError(403, "provider refused: %s", ...)follows thelog_messageconvention described in Error Handling: the text goes to the log, not to the user. -
The user cookie contains only the provider’s user id. Google’s own example in the Tornado documentation stores the access token in the cookie as well; a signed cookie is signed, not encrypted, so the token would be readable by the user and by any script that managed to read the cookie. Store tokens server-side (a database or a cache keyed by the user id) if you need them at all, and prefer not to keep them when you only needed the identity.
Trying it against a fake provider
To run this handler without a Google account, the next block builds a local identity provider with three tiny handlers (authorize, token, userinfo) on the same loopback server, and plays the browser by following redirects manually while keeping cookies. The test points the mixin at the fake provider by overriding the three URL class attributes in a subclass, which is the same mechanism that lets you add providers that Tornado does not ship:
import asyncio
import contextlib
from http.cookies import SimpleCookie
from urllib.parse import urlencode, urlparse
from tornado import httpserver, netutil
from tornado.httpclient import AsyncHTTPClient
from tornado.web import Application, RequestHandler, url
CODES: dict[str, dict] = {}
class FakeAuthorize(RequestHandler):
def get(self):
code = secrets.token_hex(8)
CODES[code] = {"challenge": self.get_argument("code_challenge", None)}
query = {"code": code, "state": self.get_argument("state")}
self.redirect(self.get_argument("redirect_uri") + "?" + urlencode(query))
class FakeToken(RequestHandler):
def post(self):
grant = CODES.pop(self.get_body_argument("code"), None)
if not grant or self.get_body_argument("client_secret") != self.settings["idp_secret"]:
self.set_status(400)
self.finish({"error": "invalid_grant"})
return
self.write({"access_token": "at-" + secrets.token_hex(8), "token_type": "Bearer"})
class FakeUserinfo(RequestHandler):
def get(self):
self.write({"id": "u-1001", "name": "Ada Reader", "email": "ada@example.test"})
@contextlib.asynccontextmanager
async def serve(routes, **settings):
sockets = netutil.bind_sockets(0, "127.0.0.1")
base = f"http://127.0.0.1:{sockets[0].getsockname()[1]}"
config = load_settings()
config.update(redirect_base_uri=base, idp_secret=config["google_oauth"]["secret"])
config.update(settings)
server = httpserver.HTTPServer(Application(routes(base), **config))
server.add_sockets(sockets)
try:
yield base
finally:
server.stop()
async def browse(base, path="/", jar=None):
"""Play the browser: follow redirects by hand, keep cookies, return the trail and last response."""
jar = SimpleCookie() if jar is None else jar
client, target, trail = AsyncHTTPClient(), base + path, []
for _ in range(8):
cookie = "; ".join(f"{k}={m.value}" for k, m in jar.items())
resp = await client.fetch(target, headers={"Cookie": cookie}, follow_redirects=False, raise_error=False)
for header in resp.headers.get_list("Set-Cookie"):
jar.load(header)
trail.append((resp.code, urlparse(target).path))
if resp.code not in (301, 302, 303):
return trail, resp
target = urljoin(target, resp.headers["Location"])
raise RuntimeError("redirect loop")
def google_routes(base):
class LocalGoogleLogin(GoogleLoginHandler): # the production handler, pointed at the fake provider
_OAUTH_AUTHORIZE_URL = base + "/idp/authorize"
_OAUTH_ACCESS_TOKEN_URL = base + "/idp/token"
_OAUTH_USERINFO_URL = base + "/idp/userinfo"
return [
url(r"/login/google", LocalGoogleLogin, name="google_callback"),
(r"/", HomeHandler),
(r"/idp/authorize", FakeAuthorize),
(r"/idp/token", FakeToken),
(r"/idp/userinfo", FakeUserinfo),
]
async def google_demo():
async with serve(google_routes) as base:
trail, resp = await browse(base)
print(trail)
print(resp.body.decode())
# A callback that carries a state the browser never stored is refused.
resp = await AsyncHTTPClient().fetch(
base + "/login/google?code=abc&state=forged", raise_error=False, follow_redirects=False
)
print(resp.code)
asyncio.run(google_demo())
Source: Tornado: AsyncHTTPClient.fetch
The output of the first print is the redirect trail the browser walks: anonymous / is redirected to the login_url,
the login handler redirects to the provider, the provider redirects back to the login handler, and the login handler
finally redirects to /, which now answers 200 with the signed-in user:
[(302, '/'), (302, '/login/google'), (302, '/idp/authorize'), (302, '/login/google'), (200, '/')]
{"user": "u-1001"}
403
The first redirect (/ to /login/google) is the work of @authenticated and the login_url setting. The forged
callback is rejected with 403 because no oauth_state cookie accompanies it.
State, PKCE and the Other Things the Mixins Do Not Do
tornado.auth is a convenience for the two HTTP requests of the code flow. Compare it with what the
OAuth security best practices expect from a client and you find that
the safeguards are all left to the application:
| Protection | In Tornado |
|---|---|
|
Not generated or checked. Create it yourself, send it with |
PKCE ( |
Not supported by |
|
Not supported. Tornado never parses the |
Token storage, refresh and revocation |
Not supported. The mixin returns the token response; refreshing and revoking are further token-endpoint calls that you write. |
Access token transport |
|
Open redirects after login |
If your login handler honours a |
Cross-site request forgery on the application |
A separate matter, handled by |
A few further pitfalls:
-
Do not build
redirect_urifrom the request. Behind a proxy the scheme and host are not what the provider registered, and a header you do not control must not decide where a code is sent. -
Authentication is not authorisation. A valid Google login proves an identity, not that this person may read the Bookshelf Live admin pages; map the provider’s stable user id (
id, orsubfor OpenID Connect) to your own user record and roles. Accept an email address as a login key only when the provider states it is verified, and never merge accounts only because two providers report the same address. -
Use HTTPS and
securecookies in production, and send theCache-Control: no-storeheader on responses that carry tokens. -
Handle failures from the provider.
get_authenticated_userraises theHTTPClientErrorthatAsyncHTTPClient.fetchraises when the token endpoint answers 4xx or 5xx (for exampleinvalid_grantfor a reused code). Turn it into a 401 or a retry page instead of letting it become an anonymous 500.
Source: Tornado: HTTPClientError
Adding Another OAuth 2.0 Provider
OAuth2Mixin is abstract: it expects the subclass to define _OAUTH_AUTHORIZE_URL and _OAUTH_ACCESS_TOKEN_URL, and
it leaves the token request to you. That is exactly the right place to add PKCE. The next example defines a
BookClub ID provider (a made-up service that requires PKCE) as a mixin of its own and does the code exchange with the
HTTP client returned by get_auth_http_client(), so tests can replace it:
import base64
import hashlib
from tornado import escape
from tornado.auth import OAuth2Mixin
class BookClubMixin(OAuth2Mixin):
_OAUTH_AUTHORIZE_URL = "https://id.bookclub.example/oauth/authorize"
_OAUTH_ACCESS_TOKEN_URL = "https://id.bookclub.example/oauth/token"
async def exchange_code(self, *, redirect_uri, code, code_verifier, client_id, client_secret):
body = urlencode({
"grant_type": "authorization_code",
"redirect_uri": redirect_uri,
"code": code,
"code_verifier": code_verifier,
"client_id": client_id,
"client_secret": client_secret,
})
response = await self.get_auth_http_client().fetch(
self._OAUTH_ACCESS_TOKEN_URL,
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
body=body,
)
return escape.json_decode(response.body)
def s256(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
class BookClubLoginHandler(BaseHandler, BookClubMixin):
async def get(self):
redirect_uri = urljoin(self.settings["redirect_base_uri"], self.reverse_url("bookclub_callback"))
creds = self.settings["bookclub_oauth"]
if not self.get_argument("code", None):
state, verifier = secrets.token_urlsafe(32), secrets.token_urlsafe(64)
self.set_signed_cookie("oauth_state", f"{state}.{verifier}", expires_days=None, **self.cookie_flags)
self.authorize_redirect(
redirect_uri=redirect_uri,
client_id=creds["key"],
scope=["profile"],
extra_params={"state": state, "code_challenge": s256(verifier), "code_challenge_method": "S256"},
)
return
stored = (self.get_signed_cookie("oauth_state", max_age_days=0.01) or b"").decode()
self.clear_cookie("oauth_state")
state, _, verifier = stored.partition(".")
if not state or not hmac.compare_digest(state, self.get_argument("state", "")):
raise web.HTTPError(403, "state mismatch")
tokens = await self.exchange_code(
redirect_uri=redirect_uri, code=self.get_argument("code"), code_verifier=verifier,
client_id=creds["key"], client_secret=creds["secret"],
)
self.set_signed_cookie("user", "bookclub:ada", expires_days=1, **self.cookie_flags)
self.redirect("/")
The verifier never leaves the server except in the token request, and the challenge is its SHA-256 digest (the
S256 method of RFC 7636). The state and the verifier are stored together in one signed cookie, since both must come
back with the same browser. The user value stored at the end is deliberately a placeholder: a real application would
call the provider’s userinfo endpoint and store the provider’s own user id.
To run it, the fake provider must now verify the challenge. The test swaps in a stricter token endpoint and points the mixin at the loopback server:
class StrictFakeToken(FakeToken):
def post(self):
grant = CODES.get(self.get_body_argument("code"))
verifier = self.get_body_argument("code_verifier", "")
if grant and grant["challenge"] != s256(verifier):
CODES.pop(self.get_body_argument("code"))
self.set_status(400)
self.finish({"error": "invalid_grant", "error_description": "PKCE verification failed"})
return
super().post()
def bookclub_routes(base):
class LocalBookClubLogin(BookClubLoginHandler):
_OAUTH_AUTHORIZE_URL = base + "/idp/authorize"
_OAUTH_ACCESS_TOKEN_URL = base + "/idp/token"
return [
url(r"/login/bookclub", LocalBookClubLogin, name="bookclub_callback"),
(r"/", HomeHandler),
(r"/idp/authorize", FakeAuthorize),
(r"/idp/token", StrictFakeToken),
]
async def bookclub_demo():
secret = secrets.token_hex(16)
async with serve(bookclub_routes, login_url="/login/bookclub",
bookclub_oauth={"key": "bookshelf-live", "secret": secret}, idp_secret=secret) as base:
trail, resp = await browse(base)
print(trail)
print(resp.body.decode())
asyncio.run(bookclub_demo())
[(302, '/'), (302, '/login/bookclub'), (302, '/idp/authorize'), (302, '/login/bookclub'), (200, '/')]
{"user": "bookclub:ada"}
Facebook and the Graph API
FacebookGraphMixin follows the same shape with two differences. get_authenticated_user(redirect_uri, client_id,
client_secret, code, extra_fields=None) takes the client credentials as arguments (there is no settings key for them,
the documentation example reads facebook_api_key and facebook_secret from the application settings) and returns a
user dictionary rather than a token response: it exchanges the code, then calls the Graph API path /me with an
appsecret_proof (an HMAC-SHA256 of the access token, keyed by the client secret) and copies the fields id, name,
first_name, last_name, locale, picture and link, plus any named in extra_fields, together with
access_token and session_expires (a string). facebook_request(path, access_token=None, post_args=None, **args) is
a thin wrapper over oauth2_request that prepends _FACEBOOK_BASE_URL.
class FacebookLoginHandler(BaseHandler, FacebookGraphMixin):
async def get(self):
redirect_uri = urljoin(self.settings["redirect_base_uri"], self.reverse_url("facebook_callback"))
creds = self.settings["facebook_oauth"]
if not self.get_argument("code", None):
state = secrets.token_urlsafe(32)
self.set_signed_cookie("oauth_state", state, expires_days=None, **self.cookie_flags)
self.authorize_redirect(
redirect_uri=redirect_uri,
client_id=creds["key"],
extra_params={"scope": "public_profile", "state": state},
)
return
expected = self.get_signed_cookie("oauth_state", max_age_days=0.01)
self.clear_cookie("oauth_state")
if expected is None or not hmac.compare_digest(expected, self.get_argument("state", "").encode()):
raise web.HTTPError(403, "state mismatch")
user = await self.get_authenticated_user(
redirect_uri=redirect_uri, client_id=creds["key"], client_secret=creds["secret"],
code=self.get_argument("code"),
)
if user is None:
raise web.HTTPError(401, "the provider returned no profile")
self.set_signed_cookie("user", "facebook:" + user["id"], expires_days=1, **self.cookie_flags)
self.redirect("/")
Run against a fake Graph API, the handler gives the same redirect trail as the others. The fake exposes the token
endpoint (a GET, because that is how this mixin calls it) and /me:
class FakeFacebookToken(RequestHandler):
def get(self):
if not CODES.pop(self.get_argument("code"), None) or self.get_argument("client_secret") != self.settings["idp_secret"]:
self.set_status(400)
self.finish({"error": {"message": "invalid code"}})
return
self.write({"access_token": "at-" + secrets.token_hex(8), "expires_in": 3600})
class FakeGraphMe(RequestHandler):
def get(self):
assert self.get_argument("appsecret_proof") # sent by the mixin
self.write({"id": "fb-77", "name": "Ada Reader"})
def facebook_routes(base):
class LocalFacebookLogin(FacebookLoginHandler):
_OAUTH_AUTHORIZE_URL = base + "/idp/authorize?"
_OAUTH_ACCESS_TOKEN_URL = base + "/graph/token?"
_FACEBOOK_BASE_URL = base + "/graph"
return [
url(r"/login/facebook", LocalFacebookLogin, name="facebook_callback"),
(r"/", HomeHandler),
(r"/idp/authorize", FakeAuthorize),
(r"/graph/token", FakeFacebookToken),
(r"/graph/me", FakeGraphMe),
]
async def facebook_demo():
secret = secrets.token_hex(16)
async with serve(facebook_routes, login_url="/login/facebook",
facebook_oauth={"key": "bookshelf-live", "secret": secret}, idp_secret=secret) as base:
trail, resp = await browse(base)
print(trail, resp.body.decode())
asyncio.run(facebook_demo())
[(302, '/'), (302, '/login/facebook'), (302, '/idp/authorize'), (302, '/login/facebook'), (200, '/')] {"user": "facebook:fb-77"}
Two cautions apply before relying on this mixin in production. The client secret travels as a query parameter of the
token request, because that is how the mixin calls the endpoint, and the Graph API paths in the module carry no API
version; Meta changes permissions, review requirements and field availability regularly, so check the current Meta
documentation for the scopes and fields you request and test against a real app in development mode. If Meta’s
requirements are not met by the mixin, the same code-for-token exchange written with AsyncHTTPClient (as in the
BookClub example) gives you full control.
Deprecated Mixins: OpenID 2.0, OAuth 1.0a and Twitter
Three parts of the module are legacy and must not be used in new code.
-
OpenIdMixin(methodsauthenticate_redirectandget_authenticated_user) implements OpenID 2.0 with Attribute Exchange. The documentation marks it deprecated since 6.6 and scheduled for removal in 6.7, and the 6.5.10 code already warns when a handler using it is instantiated, as shown below. The migration path is OpenID Connect, a different protocol built on OAuth 2.0. -
OAuthMixinis the abstract OAuth 1.0 and 1.0a client (authorize_redirect,get_authenticated_user, the_OAUTH_REQUEST_TOKEN_URL,_OAUTH_ACCESS_TOKEN_URL,_OAUTH_AUTHORIZE_URLand_OAUTH_VERSIONattributes). Its only user inside Tornado was Twitter. Nearly every provider has moved to OAuth 2.0; see OAuth 1.0 for the history of the protocol. -
TwitterMixin(authenticate_redirect,authorize_redirect,twitter_request) targets version 1.1 of the Twitter API and thetwitter_consumer_keyandtwitter_consumer_secretsettings. It has been deprecated since 6.3 because that API has been retired; the documentation says it will not be updated and will be removed. It is not shown in an example because it can no longer work against the service.
import warnings
from tornado.auth import OpenIdMixin
class LegacyOpenIdHandler(web.RequestHandler, OpenIdMixin):
def get(self):
self.write("legacy")
async def openid_demo():
async with serve(lambda base: [(r"/legacy", LegacyOpenIdHandler)]) as base:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
await AsyncHTTPClient().fetch(base + "/legacy") # the server runs in this process
print([str(w.message) for w in caught])
asyncio.run(openid_demo())
['OpenIdMixin is deprecated and will be removed in Tornado 6.7']
Run your test suite with python -W error::DeprecationWarning (or the equivalent pytest setting) so that code that
still touches these classes fails early. Do not confuse the legacy OpenIdMixin with OpenID Connect; if your
application previously used it to sign in with Google through "OpenID", the provider stopped supporting that protocol
years ago and the replacement is GoogleOAuth2Mixin or a full OpenID Connect client.
When to Use an OpenID Connect Library Instead
The mixins are a good fit when you need "sign in with Google" for an application you control, with the user identified
by the userinfo response. Reach for a dedicated library when you need any of the following, which tornado.auth does
not do:
-
OpenID Connect proper: discovery documents, the JWKS key set, ID token validation (signature,
iss,aud,exp,nonce), logout. See OpenID Connect, Discovery, Metadata & Client Registration and ID Tokens vs Access Tokens. -
Many providers behind one configuration (Google, GitHub, Microsoft, an enterprise IdP), as covered in Social Login & Federation.
-
Token lifecycle: refresh tokens, expiry handling, revocation and introspection (Access and Refresh Tokens).
-
Hardened profiles: PAR, JAR, DPoP and mTLS (PAR, JAR & Hardened Profiles).
-
Protecting an API with tokens issued elsewhere: validate JWT access tokens with a JWT library and a cached key set in
prepare(), using the HTTP client and the caching ideas of Async HTTP Client.
Authlib is a widely used library of this kind; it can be used with its own async HTTP client (HTTPX) inside a Tornado
handler, because Tornado handlers are coroutines on the asyncio loop, and a framework-agnostic OpenID Connect client
or an identity provider’s SDK can be used the same way. Whichever you choose, the three integration points with
Tornado stay the same: redirect with self.redirect(), keep the state in a signed cookie or server-side store, and
keep the user’s identifier in a signed cookie read by get_current_user.
Source: Authlib: OAuth clients
Testing Login Flows
Everything above ran without a real provider, and the technique carries over to a test suite:
-
Point the mixin at a local fake by overriding
_OAUTH_AUTHORIZE_URL,_OAUTH_ACCESS_TOKEN_URLand, for the Google and Facebook mixins,_OAUTH_USERINFO_URLor_FACEBOOK_BASE_URLon a subclass, as in the examples. These are the extension points the module documents for new providers. -
Or override
get_auth_http_client()to return a stub whosefetchreturns cannedHTTPResponseobjects; this tests the handler without any sockets. -
With
tornado.testing.AsyncHTTPTestCase, fetch withfollow_redirects=Falseand assert onLocationandSet-Cookie, the way thebrowsehelper did. -
Test the negative cases: missing
state, forgedstate, replayedcode, providererror, a provider outage. UseExpectLog(see Error Handling) to silence the expected log lines. -
Sign users in directly in other tests by creating the signed cookie with
handler.create_signed_value("user", "u-1001"), instead of replaying the whole flow in every test.
Source: Tornado: AsyncHTTPTestCase
Recent Changes
| Version | Change |
|---|---|
6.0 |
The |
6.3 |
|
6.4 |
The |
6.5.x |
No change to the public interface of |
6.6 (upcoming, only 6.6a1 published) |
|
How This Compares
| Aspect | Tornado tornado.auth |
Flask or FastAPI with Authlib |
|---|---|---|
Scope |
Two HTTP requests of the code flow, per provider. |
Client registry, discovery, ID token validation, refresh, many providers. |
|
Application code. |
Handled by the library. |
Session |
Signed cookie ( |
Framework session or token store. |
Async |
Native: mixins are coroutines. |
Async variants exist for the HTTPX client. |
For the Flask version of the same task see Flask: OAuth, Social Login & LDAP; for how Django handles social login see Django.
Summary
-
tornado.authprovides mixins:GoogleOAuth2Mixin,FacebookGraphMixinand the genericOAuth2Mixinare the current ones;OpenIdMixin,OAuthMixinandTwitterMixinare legacy. -
The flow is
authorize_redirect(), the provider, a callback withcode, thenget_authenticated_user()andoauth2_request(); one handler usually serves both legs. -
The mixins do not generate or check
state, do not support PKCE, nonces or ID token validation, and put the token in a query string; add those yourself or use an OpenID Connect library. -
Keep credentials in the environment, the user’s identifier (not the tokens) in a signed cookie, and test the whole flow against a local fake provider.