Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Implement OAuth2ClientCredentials class #11560

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions docs/de/docs/reference/security/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ from fastapi.security import (
OAuth2,
OAuth2AuthorizationCodeBearer,
OAuth2PasswordBearer,
OAuth2ClientCredentials,
OAuth2PasswordRequestForm,
OAuth2PasswordRequestFormStrict,
OpenIdConnect,
Expand Down Expand Up @@ -58,6 +59,8 @@ from fastapi.security import (

::: fastapi.security.OAuth2PasswordBearer

::: fastapi.security.OAuth2ClientCredentials

## OAuth2-Passwortformulare

::: fastapi.security.OAuth2PasswordRequestForm
Expand Down
3 changes: 3 additions & 0 deletions docs/en/docs/reference/security/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ from fastapi.security import (
OAuth2,
OAuth2AuthorizationCodeBearer,
OAuth2PasswordBearer,
OAuth2ClientCredentials,
OAuth2PasswordRequestForm,
OAuth2PasswordRequestFormStrict,
OpenIdConnect,
Expand Down Expand Up @@ -58,6 +59,8 @@ from fastapi.security import (

::: fastapi.security.OAuth2PasswordBearer

::: fastapi.security.OAuth2ClientCredentials

## OAuth2 Password Form

::: fastapi.security.OAuth2PasswordRequestForm
Expand Down
2 changes: 2 additions & 0 deletions docs/en/docs/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ hide:

# Release Notes

* Implement `OAuth2ClientCredentials` class. PR [#11560](https://github.com/tiangolo/fastapi/pull/11560) by [@rhysrevans3](https://github.com/rhysrevans3).

## Latest Changes

* 🌐 Add Turkish translation for `docs/tr/docs/tutorial/request-forms.md`. PR [#11553](https://github.com/tiangolo/fastapi/pull/11553) by [@hasansezertasan](https://github.com/hasansezertasan).
Expand Down
1 change: 1 addition & 0 deletions fastapi/security/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .http import HTTPDigest as HTTPDigest
from .oauth2 import OAuth2 as OAuth2
from .oauth2 import OAuth2AuthorizationCodeBearer as OAuth2AuthorizationCodeBearer
from .oauth2 import OAuth2ClientCredentials as OAuth2ClientCredentials
from .oauth2 import OAuth2PasswordBearer as OAuth2PasswordBearer
from .oauth2 import OAuth2PasswordRequestForm as OAuth2PasswordRequestForm
from .oauth2 import OAuth2PasswordRequestFormStrict as OAuth2PasswordRequestFormStrict
Expand Down
99 changes: 99 additions & 0 deletions fastapi/security/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,105 @@ async def __call__(self, request: Request) -> Optional[str]:
return param


class OAuth2ClientCredentials(OAuth2):
"""
OAuth2 flow for authentication using a bearer token obtained with an OAuth2 client
credentials flow. An instance of it would be used as a dependency.
"""

def __init__(
self,
tokenUrl: Annotated[
str,
Doc(
"""
The URL to obtain the OAuth2 token.
"""
),
],
scheme_name: Annotated[
Optional[str],
Doc(
"""
Security scheme name.

It will be included in the generated OpenAPI (e.g. visible at `/docs`).
"""
),
] = None,
scopes: Annotated[
Optional[Dict[str, str]],
Doc(
"""
The OAuth2 scopes that would be required by the *"path" operations* that
use this dependency.
"""
),
] = None,
description: Annotated[
Optional[str],
Doc(
"""
Security scheme description.

It will be included in the generated OpenAPI (e.g. visible at `/docs`).
"""
),
] = None,
auto_error: Annotated[
bool,
Doc(
"""
By default, if no HTTP Auhtorization header is provided, required for
OAuth2 authentication, it will automatically cancel the request and
send the client an error.

If `auto_error` is set to `False`, when the HTTP Authorization header
is not available, instead of erroring out, the dependency result will
be `None`.

This is useful when you want to have optional authentication.

It is also useful when you want to have authentication that can be
provided in one of multiple optional ways (for example, with OAuth2
or in a cookie).
"""
),
] = True,
):
if not scopes:
scopes = {}
flows = OAuthFlowsModel(
clientCredentials=cast(
Any,
{
"tokenUrl": tokenUrl,
"scopes": scopes,
},
)
)
super().__init__(
flows=flows,
scheme_name=scheme_name,
description=description,
auto_error=auto_error,
)

async def __call__(self, request: Request) -> Optional[str]:
authorization = request.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
if self.auto_error:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
else:
return None # pragma: nocover
return param


class SecurityScopes:
"""
This is a special class that you can define in a parameter in a dependency to
Expand Down
72 changes: 72 additions & 0 deletions tests/test_security_oauth2_authorization_client_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from typing import Optional

from fastapi import FastAPI, Security
from fastapi.security import OAuth2ClientCredentials
from fastapi.testclient import TestClient

app = FastAPI()

oauth2_scheme = OAuth2ClientCredentials(tokenUrl="token", auto_error=True)


@app.get("/items/")
async def read_items(token: Optional[str] = Security(oauth2_scheme)):
return {"token": token}


client = TestClient(app)


def test_no_token():
response = client.get("/items")
assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}


def test_incorrect_token():
response = client.get("/items", headers={"Authorization": "Non-existent testtoken"})
assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}


def test_token():
response = client.get("/items", headers={"Authorization": "Bearer testtoken"})
assert response.status_code == 200, response.text
assert response.json() == {"token": "testtoken"}


def test_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200, response.text
assert response.json() == {
"openapi": "3.1.0",
"info": {"title": "FastAPI", "version": "0.1.0"},
"paths": {
"/items/": {
"get": {
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
}
},
"summary": "Read Items",
"operationId": "read_items_items__get",
"security": [{"OAuth2ClientCredentials": []}],
}
}
},
"components": {
"securitySchemes": {
"OAuth2ClientCredentials": {
"type": "oauth2",
"flows": {
"clientCredentials": {
"tokenUrl": "token",
"scopes": {},
}
},
}
}
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from typing import Optional

from fastapi import FastAPI, Security
from fastapi.security import OAuth2ClientCredentials
from fastapi.testclient import TestClient

app = FastAPI()

oauth2_scheme = OAuth2ClientCredentials(
tokenUrl="token",
description="OAuth2 Client Credentials Flow",
auto_error=True,
)


@app.get("/items/")
async def read_items(token: Optional[str] = Security(oauth2_scheme)):
return {"token": token}


client = TestClient(app)


def test_no_token():
response = client.get("/items")
assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}


def test_incorrect_token():
response = client.get("/items", headers={"Authorization": "Non-existent testtoken"})
assert response.status_code == 401, response.text
assert response.json() == {"detail": "Not authenticated"}


def test_token():
response = client.get("/items", headers={"Authorization": "Bearer testtoken"})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we not setting the testtoken in a const file instead of duplicating it everywhere?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I followed the existing structure of the other oauth2 tests. I agree test_token and possibly the test_client should probably be move into fixtures but I think that is beyond the scope of this pull request. I could create a new issue for "reducing code duplication in tests"?

assert response.status_code == 200, response.text
assert response.json() == {"token": "testtoken"}


def test_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200, response.text
assert response.json() == {
"openapi": "3.1.0",
"info": {"title": "FastAPI", "version": "0.1.0"},
"paths": {
"/items/": {
"get": {
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
}
},
"summary": "Read Items",
"operationId": "read_items_items__get",
"security": [{"OAuth2ClientCredentials": []}],
}
}
},
"components": {
"securitySchemes": {
"OAuth2ClientCredentials": {
"type": "oauth2",
"flows": {
"clientCredentials": {
"tokenUrl": "token",
"scopes": {},
}
},
"description": "OAuth2 Client Credentials Flow",
}
}
},
}