Skip to content

Commit 445e3cd

Browse files
committed
refactor!: new features and big refactor of library
chose between schemes, optional auth, reintroduce idtoken_model, define scopes, and verify scope BREAKING CHANGE
1 parent 094b02d commit 445e3cd

File tree

9 files changed

+307
-46
lines changed

9 files changed

+307
-46
lines changed

.bandit.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,4 @@
8282
tests:
8383

8484
# (optional) list skipped test IDs here, eg '[B101, B406]':
85-
skips: [B101]
85+
skips: [B101, B104]

example/__init__.py

Whitespace-only changes.

example/main.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from typing import Optional
2+
3+
import uvicorn
4+
from fastapi import Depends
5+
from fastapi import FastAPI
6+
from fastapi import HTTPException
7+
from fastapi import status
8+
from fastapi.middleware.cors import CORSMiddleware
9+
from starlette.responses import RedirectResponse
10+
11+
from fastapi_oidc import Auth
12+
from fastapi_oidc import KeycloakIDToken
13+
14+
auth = Auth(
15+
openid_connect_url="http://localhost:8080/auth/realms/my-realm/.well-known/openid-configuration",
16+
issuer="http://localhost:8080/auth/realms/my-realm", # optional, verification only
17+
audience="my-audience", # optional, verification only
18+
auto_error=False, # optional
19+
idtoken_model=KeycloakIDToken, # optional
20+
)
21+
22+
app = FastAPI(title="Example", version="dev")
23+
24+
# CORS errors instead of seeing internal exceptions
25+
# https://stackoverflow.com/questions/63606055/why-do-i-get-cors-error-reason-cors-request-did-not-succeed
26+
cors = CORSMiddleware(
27+
app=app,
28+
allow_origins=["*"],
29+
allow_credentials=True,
30+
allow_methods=["*"],
31+
allow_headers=["*"],
32+
)
33+
34+
35+
@app.get("/", status_code=status.HTTP_303_SEE_OTHER)
36+
def redirect_to_docs():
37+
return RedirectResponse(url="/docs")
38+
39+
40+
@app.get("/protected", dependencies=[Depends(auth.oidc_scheme)])
41+
def protected(id_token: Optional[KeycloakIDToken] = Depends(auth.authenticate_user)):
42+
if id_token is None:
43+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
44+
45+
return dict(message=f"You are {id_token.email}")
46+
47+
48+
@app.get("/mixed", dependencies=[Depends(auth.oidc_scheme)])
49+
def mixed(id_token: Optional[KeycloakIDToken] = Depends(auth.authenticate_user)):
50+
if id_token is None:
51+
return dict(message="You are not authenticated")
52+
else:
53+
return dict(message=f"You are {id_token.email}")
54+
55+
56+
if __name__ == "__main__":
57+
uvicorn.run(
58+
"example.main:cors", host="0.0.0.0", port=8000, loop="asyncio", reload=True
59+
)

fastapi_oidc/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
from fastapi_oidc.auth import get_auth # noqa
1+
from fastapi_oidc.auth import Auth # noqa
22
from fastapi_oidc.types import IDToken # noqa
3+
from fastapi_oidc.types import KeycloakIDToken # noqa
34
from fastapi_oidc.types import OktaIDToken # noqa

fastapi_oidc/auth.py

Lines changed: 123 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -16,52 +16,114 @@ def test_auth(authenticated_user: AuthenticatedUser = Depends(authenticate_user)
1616
return f"Hello {name}"
1717
"""
1818

19-
from typing import Callable
2019
from typing import Dict
2120
from typing import Optional
21+
from typing import Type
2222

2323
from fastapi import Depends
2424
from fastapi import HTTPException
25+
from fastapi import Request
26+
from fastapi import status
27+
from fastapi.openapi.models import OAuthFlows
28+
from fastapi.security import HTTPAuthorizationCredentials
29+
from fastapi.security import HTTPBearer
30+
from fastapi.security import OAuth2
31+
from fastapi.security import OAuth2AuthorizationCodeBearer
32+
from fastapi.security import OAuth2PasswordBearer
2533
from fastapi.security import OpenIdConnect
34+
from fastapi.security import SecurityScopes
2635
from jose import ExpiredSignatureError
2736
from jose import JWTError
2837
from jose import jwt
2938
from jose.exceptions import JWTClaimsError
3039

3140
from fastapi_oidc import discovery
41+
from fastapi_oidc.types import IDToken
3242

3343

34-
def get_auth(
35-
openid_connect_url: str,
36-
issuer: Optional[str] = None,
37-
audience: Optional[str] = None,
38-
signature_cache_ttl: int = 3600,
39-
) -> Callable[[str], Dict]:
40-
"""Take configurations and returns the :func:`authenticate_user` function.
44+
class AuthBearer(HTTPBearer):
45+
async def __call__(self, request: Request):
46+
return await super().__call__(request)
4147

42-
This function should only be invoked once at the beggining of your
43-
server code. The function it returns should be used to check user credentials.
4448

45-
Args:
46-
openid_connect_url (URL): URL to the "well known" openid connect config
47-
e.g. https://dev-123456.okta.com/.well-known/openid-configuration
48-
issuer (URL): (Optional) The issuer URL from your auth server.
49-
audience (str): (Optional) The audience string configured by your auth server.
50-
signature_cache_ttl (int): How many seconds your app should cache the
51-
authorization server's public signatures.
49+
class EmptyOAuth2(OAuth2):
50+
async def __call__(self, request: Request) -> Optional[str]:
51+
return None
5252

53-
Returns:
54-
func: authenticate_user(auth_header: str) -> Dict
5553

56-
Raises:
57-
Nothing intentional
58-
"""
54+
class Auth:
55+
def __init__(
56+
self,
57+
openid_connect_url: str,
58+
issuer: Optional[str] = None,
59+
audience: Optional[str] = None,
60+
scopes: Dict[str, str] = dict(),
61+
auto_error: bool = True,
62+
signature_cache_ttl: int = 3600,
63+
idtoken_model: Type = IDToken,
64+
):
65+
"""Configure authentication and use method :func:`authenticate_user`
66+
to check user credentials.
5967
60-
oauth2_scheme = OpenIdConnect(openIdConnectUrl=openid_connect_url)
61-
62-
discover = discovery.configure(cache_ttl=signature_cache_ttl)
68+
Args:
69+
openid_connect_url (URL): URL to the "well known" openid connect config
70+
e.g. https://dev-123456.okta.com/.well-known/openid-configuration
71+
issuer (URL): (Optional) The issuer URL from your auth server.
72+
audience (str): (Optional) The audience string configured by your auth server.
73+
scopes (Dict[str, str]): (Optional) A dictionary of scopes and their descriptions.
74+
auto_error (bool): (Optional) If True, raise an HTTPException if the token is invalid.
75+
signature_cache_ttl (int): How many seconds your app should cache the
76+
authorization server's public signatures.
77+
idtoken_model (Type): (Optional) The model to use for validating the ID Token.
78+
79+
Raises:
80+
Nothing intentional
81+
"""
6382

64-
def authenticate_user(auth_header: str = Depends(oauth2_scheme)) -> Dict:
83+
self.openid_connect_url = openid_connect_url
84+
self.issuer = issuer
85+
self.audience = audience
86+
self.auto_error = auto_error
87+
self.idtoken_model = idtoken_model
88+
89+
self.discover = discovery.configure(cache_ttl=signature_cache_ttl)
90+
oidc_discoveries = self.discover.auth_server(
91+
openid_connect_url=self.openid_connect_url
92+
)
93+
94+
self.oidc_scheme = OpenIdConnect(
95+
openIdConnectUrl=openid_connect_url, auto_error=auto_error
96+
)
97+
self.password_scheme = OAuth2PasswordBearer(
98+
tokenUrl=self.discover.token_url(oidc_discoveries),
99+
scopes=scopes,
100+
)
101+
self.implicit_scheme = EmptyOAuth2(
102+
flows=OAuthFlows(
103+
implicit={
104+
"authorizationUrl": self.discover.authorization_url(
105+
oidc_discoveries
106+
),
107+
"scopes": scopes,
108+
}
109+
),
110+
scheme_name="OAuth2ImplicitBearer",
111+
auto_error=auto_error,
112+
)
113+
self.authcode_scheme = OAuth2AuthorizationCodeBearer(
114+
authorizationUrl=self.discover.authorization_url(oidc_discoveries),
115+
tokenUrl=self.discover.token_url(oidc_discoveries),
116+
# refreshUrl=self.discover.refresh_url(oidc_discoveries),
117+
scopes=scopes,
118+
)
119+
120+
def authenticate_user(
121+
self,
122+
security_scopes: SecurityScopes,
123+
authorization_credentials: Optional[HTTPAuthorizationCredentials] = Depends(
124+
AuthBearer(auto_error=False)
125+
),
126+
) -> Optional[IDToken]:
65127
"""Validate and parse OIDC ID token against issuer in config.
66128
Note this function caches the signatures and algorithms of the issuing server
67129
for signature_cache_ttl seconds.
@@ -76,27 +138,48 @@ def authenticate_user(auth_header: str = Depends(oauth2_scheme)) -> Dict:
76138
raises:
77139
HTTPException(status_code=401, detail=f"Unauthorized: {err}")
78140
"""
79-
id_token = auth_header.split(" ")[-1]
80-
OIDC_discoveries = discover.auth_server(openid_connect_url=openid_connect_url)
81-
key = discover.public_keys(OIDC_discoveries)
82-
algorithms = discover.signing_algos(OIDC_discoveries)
141+
142+
if authorization_credentials is None:
143+
if self.auto_error:
144+
raise HTTPException(
145+
status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token"
146+
)
147+
else:
148+
return None
149+
150+
oidc_discoveries = self.discover.auth_server(
151+
openid_connect_url=self.openid_connect_url
152+
)
153+
key = self.discover.public_keys(oidc_discoveries)
154+
algorithms = self.discover.signing_algos(oidc_discoveries)
83155

84156
try:
85-
return jwt.decode(
86-
id_token,
157+
id_token = jwt.decode(
158+
authorization_credentials.credentials,
87159
key,
88160
algorithms,
89-
audience=audience,
90-
issuer=issuer,
161+
audience=self.audience,
162+
issuer=self.issuer,
91163
options={
92164
# Disabled at_hash check since we aren't using the access token
93165
"verify_at_hash": False,
94-
"verify_iss": issuer is not None,
95-
"verify_aud": audience is not None,
166+
"verify_iss": self.issuer is not None,
167+
"verify_aud": self.audience is not None,
96168
},
97169
)
98-
99170
except (ExpiredSignatureError, JWTError, JWTClaimsError) as err:
100-
raise HTTPException(status_code=401, detail=f"Unauthorized: {err}")
101-
102-
return authenticate_user
171+
if self.auto_error:
172+
raise HTTPException(status_code=401, detail=f"Unauthorized: {err}")
173+
else:
174+
return None
175+
176+
if not set(security_scopes.scopes).issubset(id_token["scope"].split(" ")):
177+
if self.auto_error:
178+
raise HTTPException(
179+
status.HTTP_401_UNAUTHORIZED,
180+
detail=f"""Missing scope token, only have {id_token["scopes"]}""",
181+
)
182+
else:
183+
return None
184+
185+
return self.idtoken_model(**id_token)

fastapi_oidc/discovery.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,17 @@ def discover_auth_server(*_, openid_connect_url: str) -> Dict:
2929
configuration = r.json()
3030
return configuration
3131

32+
def get_authorization_url(OIDC_spec: Dict) -> str:
33+
return OIDC_spec["authorization_endpoint"]
34+
35+
def get_token_url(OIDC_spec: Dict) -> str:
36+
return OIDC_spec["token_endpoint"]
37+
3238
class functions:
3339
auth_server = discover_auth_server
3440
public_keys = get_authentication_server_public_keys
3541
signing_algos = get_signing_algos
42+
authorization_url = get_authorization_url
43+
token_url = get_token_url
3644

3745
return functions

fastapi_oidc/types.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class IDToken(BaseModel):
2424

2525
iss: str
2626
sub: str
27-
aud: str
27+
aud: List[str]
2828
exp: int
2929
iat: int
3030

@@ -45,3 +45,18 @@ class OktaIDToken(IDToken):
4545
name: str
4646
email: str
4747
preferred_username: str
48+
49+
50+
class KeycloakIDToken(IDToken):
51+
"""Pydantic Model for the IDToken returned by Keycloak's OIDC implementation."""
52+
53+
auth_time: int
54+
ver: int
55+
jti: str
56+
amr: List[str]
57+
idp: str
58+
nonce: str
59+
at_hash: str
60+
name: str
61+
email: str
62+
preferred_username: str

0 commit comments

Comments
 (0)