diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..9c02b32 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,18 @@ +name: docs + +on: + push: + branches: [ master ] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Run deployment script on server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.KEY_ED25519 }} + port: ${{ secrets.PORT }} + script: sh ~/fastapi-oauth2/docs/deploy.sh diff --git a/README.md b/README.md index a36c6e2..a0a3575 100644 --- a/README.md +++ b/README.md @@ -4,74 +4,17 @@ [![Python](https://img.shields.io/pypi/pyversions/fastapi-oauth2.svg?logoColor=white)](https://pypi.org/project/fastapi-oauth2/) [![FastAPI](https://img.shields.io/badge/fastapi-%E2%89%A50.68.1-009486)](https://pypi.org/project/fastapi-oauth2/) [![Tests](https://github.com/pysnippet/fastapi-oauth2/actions/workflows/tests.yml/badge.svg)](https://github.com/pysnippet/fastapi-oauth2/actions/workflows/tests.yml) -[![License](https://img.shields.io/pypi/l/fastapi-oauth2.svg)](https://github.com/pysnippet/fastapi-oauth2/blob/master/LICENSE) +[![Docs](https://github.com/pysnippet/fastapi-oauth2/actions/workflows/docs.yml/badge.svg)](https://github.com/pysnippet/fastapi-oauth2/actions/workflows/docs.yml) -FastAPI OAuth2 is a middleware-based social authentication mechanism supporting several auth providers. It depends on -the [social-core](https://github.com/python-social-auth/social-core) authentication backends. - -## Installation - -```shell -python -m pip install fastapi-oauth2 -``` - -## Configuration - -Configuration requires you to provide the JWT requisites and define the clients of the particular providers. The -middleware configuration is declared with the `OAuth2Config` and `OAuth2Client` classes. - -### OAuth2Config - -- `allow_http` - Allow insecure HTTP requests. Defaults to `False`. -- `jwt_secret` - The secret key used to sign the JWT. Defaults to `None`. -- `jwt_expires` - The expiration time of the JWT in seconds. Defaults to `900`. -- `jwt_algorithm` - The algorithm used to sign the JWT. Defaults to `HS256`. -- `clients` - The list of the OAuth2 clients. Defaults to `[]`. - -### OAuth2Client - -- `backend` - The [social-core](https://github.com/python-social-auth/social-core) authentication backend classname. -- `client_id` - The OAuth2 client ID for the particular provider. -- `client_secret` - The OAuth2 client secret for the particular provider. -- `redirect_uri` - The OAuth2 redirect URI to redirect to after success. Defaults to the base URL. -- `scope` - The OAuth2 scope for the particular provider. Defaults to `[]`. -- `claims` - Claims mapping for the certain provider. - -It is also important to mention that for the configured clients of the auth providers, the authorization URLs are -accessible by the `/oauth2/{provider}/auth` path where the `provider` variable represents the exact value of the auth -provider backend `name` attribute. - -```python -from fastapi_oauth2.claims import Claims -from fastapi_oauth2.client import OAuth2Client -from fastapi_oauth2.config import OAuth2Config -from social_core.backends.github import GithubOAuth2 - -oauth2_config = OAuth2Config( - allow_http=False, - jwt_secret=os.getenv("JWT_SECRET"), - jwt_expires=os.getenv("JWT_EXPIRES"), - jwt_algorithm=os.getenv("JWT_ALGORITHM"), - clients=[ - OAuth2Client( - backend=GithubOAuth2, - client_id=os.getenv("OAUTH2_CLIENT_ID"), - client_secret=os.getenv("OAUTH2_CLIENT_SECRET"), - redirect_uri="https://pysnippet.org/", - scope=["user:email"], - claims=Claims( - picture="avatar_url", - identity=lambda user: "%s:%s" % (user.get("provider"), user.get("id")), - ), - ), - ] -) -``` +FastAPI OAuth2 is a middleware-based social authentication mechanism supporting several OAuth2 providers. It leverages +the [social-core](https://github.com/python-social-auth/social-core) authentication backends and integrates seamlessly +with FastAPI applications. ## Integration -To integrate the package into your FastAPI application, you need to add the `OAuth2Middleware` with particular configs -in the above-represented format and include the router to the main router of the application. +For integrating the package into an existing FastAPI application, the router with OAuth2 routes and +the `OAuth2Middleware` with particular [configs](https://docs.pysnippet.org/fastapi-oauth2/integration/configuration) +should be added to the application. ```python from fastapi import FastAPI @@ -80,24 +23,14 @@ from fastapi_oauth2.router import router as oauth2_router app = FastAPI() app.include_router(oauth2_router) -app.add_middleware(OAuth2Middleware, config=oauth2_config) -``` - -After adding the middleware, the `user` attribute will be available in the request context. It will contain the user -data provided by the OAuth2 provider. - -```jinja2 -{% if request.user.is_authenticated %} - Sign out -{% else %} - Sign in -{% endif %} +app.add_middleware(OAuth2Middleware, config=OAuth2Config(...)) ``` ## Contribute -Any contribution is welcome. If you have any ideas or suggestions, feel free to open an issue or a pull request. And -don't forget to add tests for your changes. +Any contribution is welcome. Always feel free to open an issue or a discussion if you have any questions not covered by +the documentation. If you have any ideas or suggestions, please, open a pull request. Your name will shine in our +contributors' list. Be proud of what you build! ## License diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js new file mode 100644 index 0000000..4555c23 --- /dev/null +++ b/docs/.vitepress/config.js @@ -0,0 +1,45 @@ +export default { + title: "FastAPI OAuth2", + description: "OAuth2 authentication with support for several identity providers", + head: [ + ["link", {rel: "icon", type: "image/x-icon", href: "/logo.png"}], + ["link", {href: "/index.css", rel: "stylesheet"}], + ], + cleanUrls: true, + lang: "en-US", + base: "/fastapi-oauth2/", + themeConfig: { + siteTitle: "FastAPI OAuth2", + socialLinks: [ + { + icon: "github", + link: "https://github.com/pysnippet/fastapi-oauth2", + }, + { + icon: { + svg: "" + }, + link: "https://pysnippet.org/", + }, + ], + search: { + provider: "local", + }, + nav: [ + {text: "Home", link: "/"}, + {text: "Docs", link: "/integration/", activeMatch: /integration/}, + {text: "Contributing", link: "https://github.com/pysnippet/.github/blob/master/.github/CONTRIBUTING.md"}, + {text: "Releases", link: "https://github.com/pysnippet/fastapi-oauth2/releases"}, + ], + sidebar: [ + { + text: "Integration", + items: [ + {text: "Getting Started", link: "/integration/"}, + {text: "Configuration", link: "/integration/configuration"}, + {text: "Integration", link: "/integration/integration"}, + ], + }, + ], + }, +} \ No newline at end of file diff --git a/docs/deploy.sh b/docs/deploy.sh new file mode 100644 index 0000000..a5bb8d6 --- /dev/null +++ b/docs/deploy.sh @@ -0,0 +1,7 @@ +#!/bin/bash +cd ~/fastapi-oauth2/ +git restore . +git pull +sudo rm -r /var/www/docs/fastapi-oauth2/ +cd ~/fastapi-oauth2/docs/ && npm install && npm run build +sudo cp -r ~/fastapi-oauth2/docs/.vitepress/dist/ /var/www/docs/fastapi-oauth2/ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..c5496f4 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,33 @@ +--- +layout: home +sidebar: false + +title: FastAPI OAuth2 +titleTemplate: OAuth2 authentication with support for several identity providers + +hero: + name: FastAPI OAuth2 + text: OAuth2 has never been that simple + tagline: Easy to integrate OAuth2 authentication with support for several identity providers. + image: + src: /logo.png + alt: PySnippet + actions: + - theme: brand + text: Get Started + link: /integration/ + - theme: alt + text: View on GitHub + link: https://github.com/pysnippet/fastapi-oauth2 + +features: + - icon: 🛠️ + title: Free and open source + details: Enjoy the freedom of our OSS project, giving you full access to its source code and allowing you to contribute to its development. + - icon: 🧩 + title: Easy to integrate + details: Incorporate FastAPI OAuth2 into your existing projects with its straightforward integration process, saving you time. + - icon: ⚡ + title: Compatible with FastAPI 0.68.1+ + details: The package is fully compatible with FastAPI v0.68.1 and above, ensuring smooth operation and integration with your application. +--- \ No newline at end of file diff --git a/docs/integration/configuration.md b/docs/integration/configuration.md new file mode 100644 index 0000000..d0d9990 --- /dev/null +++ b/docs/integration/configuration.md @@ -0,0 +1,66 @@ +# Configuration + +The configuration for the OAuth2 clients can be provided by using the [`OAuth2Config`](#oauth2config) +and [`OAuth2Client`](#oauth2client) classes. There is an alternate way to define the configuration by using the +Python's `dict` type with the same structure as these two classes. + +## OAuth2Config + +The `OAuth2Config` class is used to define the middleware configuration, and it has the following attributes: + +- `allow_http` - Whether allow HTTP requests or not. Defaults to `False`. +- `jwt_secret` - Secret used to sign the JWT tokens. Defaults to an empty string. +- `jwt_expires` - JWT lifetime in seconds. Defaults to 900 (15 minutes). +- `jwt_algorithm` - The algorithm used to sign the JWT tokens. Defaults to `HS256`. +- `clients` - A list of [`OAuth2Client`](#oauth2client) instances. Defaults to an empty list. + +```python +OAuth2Config( + allow_http=True, + jwt_secret=os.getenv("JWT_SECRET"), + jwt_expires=os.getenv("JWT_EXPIRES"), + jwt_algorithm=os.getenv("JWT_ALGORITHM"), + clients=[ + OAuth2Client(...), + OAuth2Client(...), + ] +) +``` + +## OAuth2Client + +The `OAuth2Client` class is used to define the configuration for a given OAuth2 client, and it has the following +attributes: + +- `backend` - A backend class from the `social_core.backends` package. +- `client_id` - A string value of the generated client ID. +- `client_secret` - A string value of the generated client secret. +- `redirect_uri` - URL to redirect to after the authentication. Defaults to the base URL. +- `scope` - A list of the desired scopes. Defaults to an empty list. +- `claims` - An instance of [`Claims`](#claims) with the claim mapping definitions. + +```python +OAuth2Client( + backend=GithubOAuth2, + client_id=os.getenv("OAUTH2_GITHUB_CLIENT_ID"), + client_secret=os.getenv("OAUTH2_GITHUB_CLIENT_SECRET"), + redirect_uri="https://example.com/dashboard", + scope=["user:email"], + claims=Claims(...), +) +``` + +## Claims + +The `Claims` class is used to define the claim mapping for a given OAuth2 client, and it has `display_name`, `identity`, +`picture`, and `email` permanent attributes. It also accepts custom attributes if your case is special. Each attribute +can have a value of a string or a callable that receives the user data and returns a string. + +```python +Claims( + # Map the `picture` claim to the `avatar_url` key in the user data. + picture="avatar_url", + # Calculate the `identity` claim based on the user data. + identity=lambda user: f"{user.provider}:{user.id}", +) +``` diff --git a/docs/integration/index.md b/docs/integration/index.md new file mode 100644 index 0000000..07df73a --- /dev/null +++ b/docs/integration/index.md @@ -0,0 +1,34 @@ +# FastAPI OAuth2 + +FastAPI OAuth2 is a middleware-based social authentication mechanism supporting several OAuth2 providers. It leverages +the [social-core](https://github.com/python-social-auth/social-core) authentication backends and integrates seamlessly +with FastAPI applications. + +## Installation + +This package is compatible with Python 3.7+ and FastAPI 0.68.1+ versions and is available on The Python Package Index. +So you can install it using the package installer for Python. + +```bash +python -m pip install fastapi-oauth2 +``` + +## Upgrade + +Make sure you are using the latest version of FastAPI OAuth2 for getting the latest features and the best performance. +You can check the latest releases on its [PyPI page](https://pypi.org/project/fastapi-oauth2/). + +```bash +python -m pip install --upgrade fastapi-oauth2 +``` + +## Dependencies + +FastAPI OAuth2 depends on the following packages. + +- [fastapi](https://github.com/tiangolo/fastapi) +- [httpx](https://github.com/encode/httpx) +- [oauthlib](https://github.com/oauthlib/oauthlib) +- [python-jose](https://github.com/mpdavis/python-jose) +- [social-auth-core](https://github.com/python-social-auth/social-core) +- [starlette](https://github.com/encode/starlette) diff --git a/docs/integration/integration.md b/docs/integration/integration.md new file mode 100644 index 0000000..46d0dea --- /dev/null +++ b/docs/integration/integration.md @@ -0,0 +1,76 @@ +--- +outline: deep +--- + +# Integration + +In the previous section, were described the configuration components of the OAuth2 authentication middleware and this +section covers its integration into a FastAPI app. + +## OAuth2Middleware + +The `OAuth2Middleware` is an authentication middleware which means that its usage makes the `user` and `auth` attributes +available in the [request](https://www.starlette.io/requests/) context. It has a mandatory argument `config` of +[`OAuth2Config`](/integration/configuration#oauth2config) instance that has been discussed at the previous section and +an optional argument `callback` which is a callable that is called when the authentication succeeds. + +```python +app: FastAPI + +def on_auth_success(auth: Auth, user: User): + """This could be async function as well.""" + +app.add_middleware( + OAuth2Middleware, + config=OAuth2Config(...), + callback=on_auth_success, +) +``` + +### Auth context + +This is extended version of Starlette's [`AuthCredentials`](https://www.starlette.io/authentication/#authcredentials) +and the difference is that the `Auth` has additionally the list of the `clients` that can be used in the Jinja templates +to display them dynamically, and the `provider` is an item of the `clients` that was used to authenticate the current +user. Also, there are some methods for managing the JWT tokens: `jwt_encode`, `jwt_decode`, and `jwt_create`. + +### User context + +This is the extended version of Starlette's [`BaseUser`](https://www.starlette.io/authentication/#users) and apart from +the default `is_authenticated` and `display_name` and the extended `identity`, `picture`, and `email` properties, it +also contains all attributes of the user received from a certain provider. + +### Callback + +The `callback` is called with the [`Auth`](#auth-context) and [`User`](#user-context) arguments when the authentication +succeeds. This can be used for migrating an external user into the system of the existing application. Apart from other +OAuth2 solutions that force using their base user models, certain architectural designs, or a database from a limited +set of choices, this kind of solution gives developers freedom. + +## Router + +Router defines the endpoints that are used for the authentication and logout. The authentication is done by +the `/oauth2/{provider}/auth` endpoint and the logout is done by the `/oauth2/logout` endpoint. The `{provider}` is the +name of the provider that is going to be used for the authentication and coincides with the `name` attribute of +the `backend` provided to the certain `OAuth2Client`. + +```python +from fastapi_oauth2.router import router as oauth2_router + +app.include_router(oauth2_router) +``` + +## Security + +FastAPI's `OAuth2`, `OAuth2PasswordBearer` and `OAuth2AuthorizationCodeBearer` security models are supported, but in +case your application uses cookies for storing the authentication tokens, you can use the same named security models +from the `fastapi_oauth2.security` module. + +## Examples + +Working examples of all the above-described topics can be found in +the [examples](https://github.com/pysnippet/fastapi-oauth2/tree/master/examples) and +the [tests](https://github.com/pysnippet/fastapi-oauth2/tree/master/tests) directories of the repository. Also, feel +free to open an [issue](https://github.com/pysnippet/fastapi-oauth2/issues/new/choose) or +a [discussion](https://github.com/pysnippet/fastapi-oauth2/discussions/new/choose) if you have any questions not covered +by the documentation. diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..e462a7f --- /dev/null +++ b/docs/package.json @@ -0,0 +1,13 @@ +{ + "name": "vitepress-docs", + "version": "1.0.0", + "scripts": { + "dev": "vitepress dev", + "build": "vitepress build", + "serve": "vitepress serve" + }, + "dependencies": { + "vitepress": "^1.0.0-rc.4", + "vue": "^3.3.2" + } +} \ No newline at end of file diff --git a/docs/public/index.css b/docs/public/index.css new file mode 100644 index 0000000..6ed8f70 --- /dev/null +++ b/docs/public/index.css @@ -0,0 +1,11 @@ +:root { + --vp-c-brand: rgb(0, 148, 134); + --vp-c-brand-light: var(--vp-c-brand); + --vp-c-brand-lighter: var(--vp-c-brand); + --vp-c-brand-lightest: var(--vp-c-brand); + --vp-c-brand-dark: var(--vp-c-brand); + --vp-c-brand-darker: rgba(0, 148, 134, 0.8); + + --vp-button-brand-bg: var(--vp-c-brand); + --vp-button-brand-active-bg: var(--vp-button-brand-bg); +} diff --git a/docs/public/logo.png b/docs/public/logo.png new file mode 100644 index 0000000..ae24249 Binary files /dev/null and b/docs/public/logo.png differ diff --git a/examples/demonstration/config.py b/examples/demonstration/config.py index be64b0f..9877102 100644 --- a/examples/demonstration/config.py +++ b/examples/demonstration/config.py @@ -23,7 +23,7 @@ scope=["user:email"], claims=Claims( picture="avatar_url", - identity=lambda user: "%s:%s" % (user.get("provider"), user.get("id")), + identity=lambda user: f"{user.provider}:{user.id}", ), ), OAuth2Client( @@ -32,7 +32,7 @@ client_secret=os.getenv("OAUTH2_GOOGLE_CLIENT_SECRET"), scope=["openid", "profile", "email"], claims=Claims( - identity=lambda user: "%s:%s" % (user.get("provider"), user.get("sub")), + identity=lambda user: f"{user.provider}:{user.sub}", ), ), ] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index a388b0a..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -fastapi>=0.68.1 -httpx>=0.23.0 -oauthlib>=3.2.2 -python-jose>=3.3.0 -social-auth-core>=4.4.2 -starlette>=0.19.1 diff --git a/setup.cfg b/setup.cfg index 81b6a1d..f8a7875 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,11 +3,11 @@ name = fastapi-oauth2 version = attr: fastapi_oauth2.__version__ author = Artyom Vancyan author_email = artyom@pysnippet.org -description = Easy to setup OAuth2 authentication with support for several auth providers. +description = Easy to integrate OAuth2 authentication with support for several identity providers. long_description = file: README.md long_description_content_type = text/markdown project_urls = - Documentation=https://github.com/pysnippet/fastapi-oauth2/ + Documentation=https://docs.pysnippet.org/fastapi-oauth2/ Source Code=https://github.com/pysnippet/fastapi-oauth2/ keywords = python @@ -27,7 +27,7 @@ license_files = LICENSE platforms = unix, linux, osx, win32 classifiers = Operating System :: OS Independent - Development Status :: 3 - Alpha + Development Status :: 4 - Beta Framework :: FastAPI Programming Language :: Python Programming Language :: Python :: 3 diff --git a/src/fastapi_oauth2/__init__.py b/src/fastapi_oauth2/__init__.py index 5186ae4..b63a7ea 100644 --- a/src/fastapi_oauth2/__init__.py +++ b/src/fastapi_oauth2/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0-alpha.2" +__version__ = "1.0.0-beta" diff --git a/src/fastapi_oauth2/middleware.py b/src/fastapi_oauth2/middleware.py index 5dd5eb1..58dc564 100644 --- a/src/fastapi_oauth2/middleware.py +++ b/src/fastapi_oauth2/middleware.py @@ -115,6 +115,8 @@ def __getprop__(self, item, default="") -> Any: return item(self) return self.get(item, default) + __getattr__ = __getprop__ + class OAuth2Backend(AuthenticationBackend): """Authentication backend for AuthenticationMiddleware.""" diff --git a/tests/conftest.py b/tests/conftest.py index b96e6c5..ac6f86b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,9 +54,13 @@ def user(request: Request, _: str = Depends(oauth2)): @app_router.get("/auth") def auth(request: Request): access_token = request.auth.jwt_create({ - "name": "test", - "sub": "test", - "id": "test", + "id": 54321, + "followers": 80, + "sub": "1234567890", + "name": "John Doe", + "provider": "github", + "emails": ["john.doe@test.py"], + "image": "https://example.com/john.doe.png", }) response = Response() response.set_cookie( diff --git a/tests/test_claims.py b/tests/test_claims.py new file mode 100644 index 0000000..f607d22 --- /dev/null +++ b/tests/test_claims.py @@ -0,0 +1,52 @@ +import pytest +from fastapi import APIRouter +from fastapi import Request +from httpx import AsyncClient + +from fastapi_oauth2.claims import Claims + + +@pytest.mark.anyio +async def test_permanent_claims_mapping(get_app): + app = get_app() + router = APIRouter() + + @router.get("/test_claims") + def test_claims(request: Request): + user = request.user.use_claims(Claims()) # use default claims mapping + assert user.display_name == "John Doe" + assert user.identity == "1234567890" + assert user.picture == "" + assert user.email == "" + + app.include_router(router) + + async with AsyncClient(app=app, base_url="http://test") as client: + await client.get("/auth") # Simulate login + await client.get("/test_claims") + + +@pytest.mark.anyio +async def test_custom_claims_mapping(get_app): + app = get_app() + router = APIRouter() + + @router.get("/test_claims") + def test_claims(request: Request): + user = request.user.use_claims(Claims( + picture="image", + email=lambda u: u.emails[0], + identity=lambda u: f"{u.provider}:{u.sub}", + is_popular=lambda u: u.followers > 100, + )) # use custom claims mapping + assert user.display_name == "John Doe" + assert user.identity == "github:1234567890" + assert user.picture == "https://example.com/john.doe.png" + assert user.email == "john.doe@test.py" + assert not user.is_popular + + app.include_router(router) + + async with AsyncClient(app=app, base_url="http://test") as client: + await client.get("/auth") # Simulate login + await client.get("/test_claims")