Skip to content

Commit 1eb98e0

Browse files
Make access token accessible from the auth context (GH-20)
2 parents c6c23ea + 93ff3c7 commit 1eb98e0

File tree

8 files changed

+210
-33
lines changed

8 files changed

+210
-33
lines changed

docs/.vitepress/config.js

Lines changed: 11 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/integration/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,5 @@ Claims(
6464
identity=lambda user: f"{user.provider}:{user.id}",
6565
)
6666
```
67+
68+
Check out the [tutorial](/references/tutorials#claims-mapping) on claims mapping for a clearer understanding.

docs/integration/integration.md

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ also contains all attributes of the user received from a certain provider.
4343
### Callback
4444

4545
The `callback` is called with the [`Auth`](#auth-context) and [`User`](#user-context) arguments when the authentication
46-
succeeds. This can be used for migrating an external user into the system of the existing application. Apart from other
47-
OAuth2 solutions that force using their base user models, certain architectural designs, or a database from a limited
48-
set of choices, this kind of solution gives developers freedom.
46+
succeeds. This can be used for [user provisioning](/references/tutorials#user-provisioning). Apart from other OAuth2
47+
solutions that force using their base user models, certain architectural designs, or a database from a limited set of
48+
choices, this kind of solution gives developers freedom.
4949

5050
## Router
5151

@@ -65,12 +65,3 @@ app.include_router(oauth2_router)
6565
FastAPI's `OAuth2`, `OAuth2PasswordBearer` and `OAuth2AuthorizationCodeBearer` security models are supported, but in
6666
case your application uses cookies for storing the authentication tokens, you can use the same named security models
6767
from the `fastapi_oauth2.security` module.
68-
69-
## Examples
70-
71-
Working examples of all the above-described topics can be found in
72-
the [examples](https://github.com/pysnippet/fastapi-oauth2/tree/master/examples) and
73-
the [tests](https://github.com/pysnippet/fastapi-oauth2/tree/master/tests) directories of the repository. Also, feel
74-
free to open an [issue](https://github.com/pysnippet/fastapi-oauth2/issues/new/choose) or
75-
a [discussion](https://github.com/pysnippet/fastapi-oauth2/discussions/new/choose) if you have any questions not covered
76-
by the documentation.

docs/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
"serve": "vitepress serve"
88
},
99
"dependencies": {
10+
"mermaid": "^10.3.1",
1011
"vitepress": "^1.0.0-rc.4",
12+
"vitepress-plugin-mermaid": "^2.0.14",
1113
"vue": "^3.3.2"
1214
}
1315
}

docs/references/index.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Features
2+
3+
Below is the incomplete list of features that are supported by the library. Other features and samples can be found in
4+
the [tutorials](/references/tutorials) section.
5+
6+
## Several providers
7+
8+
The library leverages the [social-core](https://github.com/python-social-auth/social-core)
9+
authentication [backends](https://github.com/python-social-auth/social-core/tree/master/social_core/backends), which
10+
means it supports all the providers that are supported by it. However, if the provider you are interested in does not
11+
exist in the list, you can add one by following
12+
the [documentation](https://python-social-auth.readthedocs.io/en/latest/backends/implementation.html).
13+
14+
## SSR & REST APIs
15+
16+
::: tip Ticket #19
17+
18+
This upcoming feature is under development and will be available in the next release. You can track the progress in
19+
the [#19](https://github.com/pysnippet/fastapi-oauth2/issues/19) issue.
20+
21+
:::
22+
23+
## CSRF protection
24+
25+
CSRF protection is enabled by default which means when the user opens the `/oauth2/{provider}/auth` endpoint it
26+
redirects to the authorization endpoint of the IDP with an autogenerated `state` parameter and saves it in the session
27+
storage. After authorization, when the `/oauth2/{provider}/token` callback endpoint gets called with the
28+
provided `state`, the `oauthlib` validates it and then redirects to the `redirect_uri`.
29+
30+
## PKCE support
31+
32+
::: tip Ticket #18
33+
34+
PKCE support is under development and will be available in the next release. You can track the progress in
35+
the [#18](https://github.com/pysnippet/fastapi-oauth2/issues/18) issue.
36+
37+
:::
38+
39+
<style>
40+
.tip {
41+
border: 0;
42+
}
43+
</style>

docs/references/tutorials.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# Tutorials
6+
7+
This documentation section covers samples and tutorials on important topics of using the library. Look at
8+
the [examples](https://github.com/pysnippet/fastapi-oauth2/tree/master/examples)
9+
and [tests](https://github.com/pysnippet/fastapi-oauth2/tree/master/tests) directories of the repository for other
10+
use-case implementations. Feel free to open an [issue](https://github.com/pysnippet/fastapi-oauth2/issues/new/choose) or
11+
a [discussion](https://github.com/pysnippet/fastapi-oauth2/discussions/new/choose) if your question is not covered by
12+
the documentation.
13+
14+
## Authentication
15+
16+
By following the [integration](/integration/integration) docs, for the basic user authentication, you must already have
17+
generated the client ID and secret to configure your `OAuth2Middleware` with at least one client configuration.
18+
19+
1. Go to the developer console or settings of your OAuth2 identity provider and generate new client credentials.
20+
2. Provide the [client configuration](/integration/configuration#oauth2client) with the obtained client ID and secret
21+
into the clients of the middleware's config.
22+
3. Set the `redirect_uri` of your application that you have also configured in the IDP.
23+
4. Add the middleware and include the router to your application as shown in the [integration](/integration/integration)
24+
section.
25+
5. Open the `/oauth2/{provider}/auth` endpoint on your browser and test the authentication flow. Check out
26+
the [router](/integration/integration#router) for the `{provider}` variable.
27+
28+
Once the authentication is successful, the user will be redirected to the `redirect_uri` and the `request.user` will
29+
contain the user information obtained from the IDP.
30+
31+
## Access token
32+
33+
When the user is authenticated, the `request.user` will contain the user information obtained from the IDP and
34+
the `request.auth` will contain the authentication related information including the access token issued by the IDP. It
35+
can be used to perform authorized requests to the IDP's API endpoints. Just make sure the token is issued with the
36+
scopes required for the API endpoint.
37+
38+
::: details `request.auth.provider.access_token`
39+
40+
```mermaid
41+
flowchart TB
42+
subgraph level2["request (Starlette's Request object)"]
43+
direction TB
44+
subgraph level1["auth (Starlette's extended Auth Credentials)"]
45+
direction TB
46+
subgraph level0["provider (OAuth2 provider with client's credentials)"]
47+
direction TB
48+
token["access_token (Access token for the specified scopes)"]
49+
end
50+
end
51+
end
52+
style level2 fill:#00948680,color:#f6f6f7,stroke:#3c3c43;
53+
style level1 fill:#2b75a080,color:#f6f6f7,stroke:#3c3c43;
54+
style level0 fill:#5c837480,color:#f6f6f7,stroke:#3c3c43;
55+
style token fill:#44506980,color:#f6f6f7,stroke:#3c3c43;
56+
```
57+
58+
:::
59+
60+
## Claims mapping
61+
62+
The `Claims` class includes permanent attributes like `display_name`, `identity`, `picture`, and `email`. It also allows
63+
for custom attributes. Each attribute can either be a string or a callable function that takes user data and returns a
64+
string. Suppose the user data obtained from IDP looks like follows, and you need to map the corresponding attributes for
65+
the user provisioning and other stuff.
66+
67+
```json
68+
{
69+
"id": 54321,
70+
"sub": "1234567890",
71+
"name": "John Doe",
72+
"provider": "github",
73+
"emails": [
74+
"john.doe@test.py"
75+
],
76+
"avatar_url": "https://example.com/john.doe.png"
77+
}
78+
```
79+
80+
It looks easy for the `picture` and `display_name` attributes, but how to map `email` from `emails` or create a
81+
unique `identity` attribute. Well, that is where the callable functions come in handy. You can use the `lambda` function
82+
to map the attributes as follows.
83+
84+
```python
85+
Claims(
86+
picture="image",
87+
display_name="avatar_url",
88+
email=lambda u: u.emails[0],
89+
identity=lambda u: f"{u.provider}:{u.sub}",
90+
)
91+
```
92+
93+
::: info NOTE
94+
95+
Not all IDPs provide the `first_name` and the `last_name` attributes already joined as in the example above, or
96+
the email in a list. So you are given the flexibility using transformer function to map the attributes as you want.
97+
98+
```mermaid
99+
flowchart LR
100+
IDPUserData("display_name string")
101+
FastAPIUserData("first_name string\nlast_name string")
102+
Transform[["transform into desired format"]]
103+
FastAPIUserData --> Transform
104+
Transform --> IDPUserData
105+
```
106+
107+
:::
108+
109+
## User provisioning
110+
111+
User provisioning refers to the process of creating, updating, and deleting user accounts within the OAuth2 IDP and
112+
synchronizing that information with your FastAPI application's database. There are two approaches to user provisioning
113+
and both require the user claims to be mapped properly for creating a new user or updating an existing one.
114+
115+
### Automatic provisioning
116+
117+
After successful authentication, you can automatically create a user in your application's database using the
118+
information obtained from the IDP. The user creation or update can be handled at the `callback` function of the
119+
[middleware](/integration/integration#oauth2middleware) as it is called when authentication succeeds.
120+
121+
### Manual provisioning
122+
123+
After successful authentication, redirect the user to a registration form where they can complete their profile. This
124+
approach is useful when there missing mandatory attributes in `request.user` for creating a user in your application's
125+
database. You need to define a route for provisioning and provide it as `redirect_uri`, so
126+
the [user context](/integration/integration#user-context) will be available for usage.
127+
128+
::: info NOTE
129+
130+
In both scenarios, it is recommended to use the `identity` attribute for uniquely identifying the user from the
131+
database. So if the application uses or plans to use multiple IDPs, make sure to include the `provider` attribute when
132+
calculating the `identity` attribute.
133+
134+
:::
135+
136+
<style>
137+
.info, .details {
138+
border: 0;
139+
}
140+
</style>

src/fastapi_oauth2/core.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,20 +67,19 @@ def __init__(self, client: OAuth2Client) -> None:
6767
self.backend = client.backend(OAuth2Strategy())
6868
self.authorization_endpoint = client.backend.AUTHORIZATION_URL
6969
self.token_endpoint = client.backend.ACCESS_TOKEN_URL
70+
self._oauth_client = WebApplicationClient(self.client_id)
7071

7172
@property
72-
def oauth_client(self) -> WebApplicationClient:
73-
if self._oauth_client is None:
74-
self._oauth_client = WebApplicationClient(self.client_id)
75-
return self._oauth_client
73+
def access_token(self) -> str:
74+
return self._oauth_client.access_token
7675

7776
def get_redirect_uri(self, request: Request) -> str:
7877
return urljoin(str(request.base_url), "/oauth2/%s/token" % self.provider)
7978

8079
async def login_redirect(self, request: Request) -> RedirectResponse:
8180
redirect_uri = self.get_redirect_uri(request)
8281
state = "".join([random.choice(string.ascii_letters) for _ in range(32)])
83-
return RedirectResponse(str(self.oauth_client.prepare_request_uri(
82+
return RedirectResponse(str(self._oauth_client.prepare_request_uri(
8483
self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope
8584
)), 303)
8685

@@ -95,7 +94,7 @@ async def token_redirect(self, request: Request) -> RedirectResponse:
9594
current_url = re.sub(r"^https?", scheme, str(url))
9695
redirect_uri = self.get_redirect_uri(request)
9796

98-
token_url, headers, content = self.oauth_client.prepare_token_request(
97+
token_url, headers, content = self._oauth_client.prepare_token_request(
9998
self.token_endpoint,
10099
redirect_url=redirect_uri,
101100
authorization_response=current_url,
@@ -111,8 +110,8 @@ async def token_redirect(self, request: Request) -> RedirectResponse:
111110
async with httpx.AsyncClient() as session:
112111
response = await session.post(token_url, headers=headers, content=content, auth=auth)
113112
try:
114-
token = self.oauth_client.parse_request_body_response(json.dumps(response.json()))
115-
token_data = self.standardize(self.backend.user_data(token.get("access_token")))
113+
self._oauth_client.parse_request_body_response(json.dumps(response.json()))
114+
token_data = self.standardize(self.backend.user_data(self.access_token))
116115
access_token = request.auth.jwt_create(token_data)
117116
except (CustomOAuth2Error, Exception) as e:
118117
raise OAuth2LoginError(400, str(e))

src/fastapi_oauth2/middleware.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,9 @@ class Auth(AuthCredentials):
3636
expires: int
3737
algorithm: str
3838
scopes: List[str]
39+
provider: OAuth2Core = None
3940
clients: Dict[str, OAuth2Core] = {}
4041

41-
_provider: OAuth2Core = None
42-
43-
@property
44-
def provider(self) -> Union[OAuth2Core, None]:
45-
return self._provider
46-
47-
@provider.setter
48-
def provider(self, identifier) -> None:
49-
self._provider = self.clients.get(identifier)
50-
5142
@classmethod
5243
def set_http(cls, http: bool) -> None:
5344
cls.http = http
@@ -146,7 +137,7 @@ async def authenticate(self, request: Request) -> Optional[Tuple[Auth, User]]:
146137

147138
user = User(Auth.jwt_decode(param))
148139
auth = Auth(user.pop("scope", []))
149-
auth.provider = user.get("provider")
140+
auth.provider = auth.clients.get(user.get("provider"))
150141
claims = auth.provider.claims if auth.provider else {}
151142

152143
# Call the callback function on authentication

0 commit comments

Comments
 (0)