Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 8 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,11 @@ Here is an example of how to use FastID for authentication in a Python applicati
[FastAPI](https://fastapi.tiangolo.com/) framework and the [httpx](https://www.python-httpx.org/) library.

```python
from typing import Any, Annotated
from typing import Any
from urllib.parse import urlencode

import httpx
from fastapi import FastAPI, Response, Request, Depends, HTTPException, status
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse

FASTID_URL = "http://localhost:8012"
Expand All @@ -119,14 +119,15 @@ def login(request: Request) -> Any:
"response_type": "code",
"client_id": FASTID_CLIENT_ID,
"redirect_uri": request.url_for("callback"),
"scope": "openid",
}
url = f"{FASTID_URL}/authorize?{urlencode(params)}"
return RedirectResponse(url=url)


@app.get("/callback")
def callback(code: str) -> Any:
token_data = httpx.post(
response = httpx.post(
f"{FASTID_URL}/api/v1/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
Expand All @@ -136,35 +137,20 @@ def callback(code: str) -> Any:
"code": code,
},
)
token = token_data.json()
response = Response(content="You are now logged in!")
response.set_cookie("access_token", token["access_token"])
return response


def current_user(request: Request) -> dict[str, Any]:
token = request.cookies.get("access_token")
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No access token")
token = response.json()["access_token"]
response = httpx.get(
f"{FASTID_URL}/api/v1/userinfo",
headers={"Authorization": f"Bearer {token}"},
)
return response.json()


@app.get("/test")
def test(user: Annotated[dict[str, Any], Depends(current_user)]) -> Any:
return user
```

In this example, we define three routes:
In this example, we define two routes:

1. `/login`: Redirects the user to the FastID authorization page.
2. `/callback`: Handles the callback from FastID after the user has logged in. It exchanges the authorization code for
an access token and sets it as a cookie.
3. `/test`: A protected route that requires the user to be logged in. It retrieves the user's information from FastID
using the access token.
an access token and retrieves the user's information.

Run the FastAPI application:

Expand All @@ -173,8 +159,7 @@ fastapi dev examples/httpx.py
```

Visit [http://localhost:8000/login](http://localhost:8000/login) to start the authentication process. After logging in,
you will be redirected to the `/callback` route, where the access token will be set as a cookie. You can then
access the `/test` route to retrieve the user's information.
you will be redirected to the `/callback` route, where you can see the user's information.

![Test Response](img/test_response.png)

Expand Down
71 changes: 48 additions & 23 deletions docs/docs/tutorial/get_started.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Get Started

## Create App

To start using FastID, you need to [create](http://localhost:8012/admin/app/create) an application in the admin panel.
This will allow you to use FastID for
authentication in your application.
Expand All @@ -9,15 +11,17 @@ authentication in your application.
Once you have created an application, you can use the standard OAuth 2.0 flow to authenticate users. FastID supports the
authorization code flow, which is the most secure and recommended way to authenticate users.

## HTTPX example

Here is an example of how to use FastID for authentication in a Python application using the
[FastAPI](https://fastapi.tiangolo.com/) framework and the [httpx](https://www.python-httpx.org/) library.

```python
from typing import Any, Annotated
from typing import Any
from urllib.parse import urlencode

import httpx
from fastapi import FastAPI, Response, Request, Depends, HTTPException, status
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse

FASTID_URL = "http://localhost:8012"
Expand All @@ -33,14 +37,15 @@ def login(request: Request) -> Any:
"response_type": "code",
"client_id": FASTID_CLIENT_ID,
"redirect_uri": request.url_for("callback"),
"scope": "openid",
}
url = f"{FASTID_URL}/authorize?{urlencode(params)}"
return RedirectResponse(url=url)


@app.get("/callback")
def callback(code: str) -> Any:
token_data = httpx.post(
response = httpx.post(
f"{FASTID_URL}/api/v1/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
Expand All @@ -50,35 +55,56 @@ def callback(code: str) -> Any:
"code": code,
},
)
token = token_data.json()
response = Response(content="You are now logged in!")
response.set_cookie("access_token", token["access_token"])
return response


def current_user(request: Request) -> dict[str, Any]:
token = request.cookies.get("access_token")
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No access token")
token = response.json()["access_token"]
response = httpx.get(
f"{FASTID_URL}/api/v1/userinfo",
headers={"Authorization": f"Bearer {token}"},
)
return response.json()

```

## FastLink example

You can also use the [FastLink](https://github.com/everysoftware/fastlink) as a faster and safer way:

@app.get("/test")
def test(user: Annotated[dict[str, Any], Depends(current_user)]) -> Any:
return user
```python
from typing import Annotated, Any

from fastapi import Depends, FastAPI
from fastapi.responses import RedirectResponse
from fastlink import FastLink
from fastlink.schemas import OAuth2Callback, ProviderMeta

app = FastAPI()
fastid = FastLink(
ProviderMeta(server_url="http://localhost:8012", scope=["openid"]),
..., # Client ID
..., # Client Secret
"http://localhost:8000/callback",
)


@app.get("/login")
async def login() -> Any:
async with fastid:
url = await fastid.login_url()
return RedirectResponse(url=url)


@app.get("/callback")
async def callback(call: Annotated[OAuth2Callback, Depends()]) -> Any:
async with fastid:
return await fastid.callback_raw(call)
```

In this example, we define three routes:
## Results

In this example, we define two routes:

1. `/login`: Redirects the user to the FastID authorization page.
2. `/callback`: Handles the callback from FastID after the user has logged in. It exchanges the authorization code for
an access token and sets it as a cookie.
3. `/test`: A protected route that requires the user to be logged in. It retrieves the user's information from FastID
using the access token.
an access token and retrieves the user's information.

Run the FastAPI application:

Expand All @@ -87,7 +113,6 @@ fastapi dev examples/httpx.py
```

Visit [http://localhost:8000/login](http://localhost:8000/login) to start the authentication process. After logging in,
you will be redirected to the `/callback` route, where the access token will be set as a cookie. You can then
access the `/test` route to retrieve the user's information.
you will be redirected to the `/callback` route, where you can see the user's information.

![Sign In](../img/test_response.png)
![Test Response](../img/test_response.png)
7 changes: 2 additions & 5 deletions examples/fastlink.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from fastapi.responses import RedirectResponse
from fastlink import FastLink
from fastlink.schemas import OAuth2Callback, ProviderMeta
from starlette.responses import JSONResponse

from examples.config import settings

Expand All @@ -25,8 +24,6 @@ async def login() -> Any:


@app.get("/callback")
async def oauth_callback(callback: Annotated[OAuth2Callback, Depends()]) -> Any:
async def callback(call: Annotated[OAuth2Callback, Depends()]) -> Any:
async with fastid:
await fastid.login(callback)
user = await fastid.userinfo()
return JSONResponse(content=user)
return await fastid.callback_raw(call)
25 changes: 6 additions & 19 deletions examples/httpx.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from typing import Annotated, Any
from typing import Any
from urllib.parse import urlencode

import httpx
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse

from examples.config import settings
Expand All @@ -16,14 +16,15 @@ def login(request: Request) -> Any:
"response_type": "code",
"client_id": settings.client_id,
"redirect_uri": request.url_for("callback"),
"scope": "openid",
}
url = f"{settings.fastid_url}/authorize?{urlencode(params)}"
return RedirectResponse(url=url)


@app.get("/callback")
def callback(code: str) -> Any:
token_data = httpx.post(
response = httpx.post(
f"{settings.fastid_url}/api/v1/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
Expand All @@ -33,23 +34,9 @@ def callback(code: str) -> Any:
"code": code,
},
)
token = token_data.json()
response = Response(content="You are now logged in!")
response.set_cookie("access_token", token["access_token"])
return response


def current_user(request: Request) -> dict[str, Any]:
token = request.cookies.get("access_token")
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No access token")
token = response.json()["access_token"]
response = httpx.get(
f"{settings.fastid_url}/api/v1/userinfo",
headers={"Authorization": f"Bearer {token}"},
)
return response.json() # type: ignore[no-any-return]


@app.get("/test")
def test(user: Annotated[dict[str, Any], Depends(current_user)]) -> Any:
return user
return response.json()
26 changes: 20 additions & 6 deletions fastid/auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,6 @@

header_transport = HeaderTransport()
cookie_transport = CookieTransport(name="fastidaccesstoken", max_age=auth_settings.jwt_access_expires_in)
verify_token_transport = CookieTransport(
name="fastidverifytoken",
scheme_name="VerifyTokenCookie",
max_age=auth_settings.jwt_verify_token_expires_in,
)
auth_bus = AuthBus(header_transport, cookie_transport)


Expand All @@ -36,12 +31,31 @@ async def get_user(
user_dep = Depends(get_user)
UserDep = Annotated[User, user_dep]

vt_transport = CookieTransport(
name="fastidverifytoken",
scheme_name="VerifyTokenCookie",
max_age=auth_settings.jwt_verify_token_expires_in,
)


async def get_optional_user(service: AuthDep, request: Request) -> User | None:
async def get_user_by_vt(
service: AuthDep,
token: Annotated[str, Depends(vt_transport)],
) -> User:
return await service.get_userinfo(token, token_type="verify") # noqa: S106


UserVTDep = Annotated[User, Depends(get_user_by_vt)]


async def get_user_or_none(service: AuthDep, request: Request) -> User | None:
token = auth_bus.parse_request(request, auto_error=False)
if token is None:
return None
try:
return await service.get_userinfo(token)
except ClientError:
return None


UserOrNoneDep = Annotated[User | None, Depends(get_user_or_none)]
10 changes: 1 addition & 9 deletions fastid/auth/permissions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
from fastapi import Request

from fastid.auth.dependencies import UserDep, verify_token_transport
from fastid.auth.dependencies import UserDep
from fastid.auth.exceptions import NoPermissionError
from fastid.auth.models import User
from fastid.security.jwt import jwt_backend
Expand All @@ -13,26 +11,20 @@ def __init__(
superuser: bool | None = None,
email_verified: bool | None = None,
active: bool | None = None,
action_verified: bool | None = None,
) -> None:
self.superuser = superuser
self.email_verified = email_verified
self.active = active
self.action_verified = action_verified
self.token_backend = jwt_backend

async def __call__(
self,
user: UserDep,
request: Request,
) -> User:
verify_token = verify_token_transport.get_token(request)
if self.superuser is not None and user.is_superuser != self.superuser:
raise NoPermissionError
if self.email_verified is not None and user.is_verified != self.email_verified: # pragma: nocover
raise NoPermissionError
if self.active is not None and user.is_active != self.active: # pragma: nocover
raise NoPermissionError
if self.action_verified and (verify_token is None or not self.token_backend.validate("verify", verify_token)):
raise NoPermissionError
return user
1 change: 1 addition & 0 deletions fastid/auth/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class UserUpdate(BaseModel):

class UserChangeEmail(BaseModel):
new_email: str
code: str


class UserChangePassword(BaseModel):
Expand Down
4 changes: 2 additions & 2 deletions fastid/auth/use_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ async def register(self, dto: UserCreate) -> User:
await self.uow.commit()
return user

async def get_userinfo(self, token: str) -> User:
async def get_userinfo(self, token: str, *, token_type: str = "access") -> User: # noqa: S107
try:
payload = jwt_backend.validate("access", token)
payload = jwt_backend.validate(token_type, token)
except FastLinkError as e:
raise InvalidTokenError from e
try:
Expand Down
3 changes: 1 addition & 2 deletions fastid/notify/clients/smtp.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ def _send(self, notification: Notification) -> None:
self._client.send_message(msg)

async def __aenter__(self) -> Self:
self._client.__enter__()
return self

async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self._client.__exit__(exc_type, exc_val, exc_tb)
pass
Loading