Skip to content

Commit fa891ac

Browse files
committed
improve: verify scopes
1 parent ba1cb49 commit fa891ac

File tree

7 files changed

+39
-39
lines changed

7 files changed

+39
-39
lines changed

.pre-commit-config.yaml

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,3 @@ repos:
4242
hooks:
4343
- id: bandit
4444
entry: bandit -c .bandit.yml
45-
46-
- repo: local
47-
hooks:
48-
- id: pytest
49-
name: pytest
50-
entry: .venv/bin/pytest
51-
language: script
52-
pass_filenames: false
53-
# alternatively you could `types: [python]` so it only runs when python files change
54-
# though tests might be invalidated if you were to say change a data file
55-
always_run: true

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,16 @@ auth = Auth(
6565
openid_connect_url="http://localhost:8080/auth/realms/my-realm/.well-known/openid-configuration",
6666
issuer="http://localhost:8080/auth/realms/my-realm", # optional, verification only
6767
client_id="my-client", # optional, verification only
68-
idtoken_model=KeycloakIDToken, # optional
68+
scopes=["email"], # optional, verification only
69+
idtoken_model=KeycloakIDToken, # optional, verification only
6970
)
7071

7172
app = FastAPI(
7273
title="Example",
7374
version="dev",
74-
dependencies=[Depends(auth.implicit_scheme)],
75+
dependencies=[Depends(auth.implicit_scheme)], # multiple schemes available
7576
)
7677

77-
7878
@app.get("/protected")
7979
def protected(id_token: KeycloakIDToken = Security(auth.required)):
8080
return dict(message=f"You are {id_token.email}")

example/main.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
openid_connect_url="http://localhost:8080/auth/realms/my-realm/.well-known/openid-configuration",
1616
issuer="http://localhost:8080/auth/realms/my-realm", # optional, verification only
1717
client_id="my-client", # optional, verification only
18-
idtoken_model=KeycloakIDToken, # optional
18+
scopes=["email"], # optional, verification only
19+
idtoken_model=KeycloakIDToken, # optional, verification only
1920
)
2021

2122
app = FastAPI(
@@ -46,9 +47,7 @@ def protected(id_token: KeycloakIDToken = Security(auth.required)):
4647

4748

4849
@app.get("/mixed")
49-
def mixed(
50-
id_token: Optional[KeycloakIDToken] = Security(auth.optional),
51-
):
50+
def mixed(id_token: Optional[KeycloakIDToken] = Security(auth.optional)):
5251
if id_token is None:
5352
return dict(message="You are not authenticated")
5453
else:

fastapi_oidc/auth.py

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

19-
from typing import Dict
19+
from typing import List
2020
from typing import Optional
2121
from typing import Type
2222

@@ -52,11 +52,11 @@ def __init__(
5252
openid_connect_url: str,
5353
issuer: Optional[str] = None,
5454
client_id: Optional[str] = None,
55-
scopes: Dict[str, str] = dict(),
55+
scopes: List[str] = list(),
5656
signature_cache_ttl: int = 3600,
5757
idtoken_model: Type = IDToken,
5858
):
59-
"""Configure authentication and use method :func:`authenticate_user`
59+
"""Configure authentication and use method :func:`require` or :func:`optional`
6060
to check user credentials.
6161
6262
Args:
@@ -77,19 +77,23 @@ def __init__(
7777
self.issuer = issuer
7878
self.client_id = client_id
7979
self.idtoken_model = idtoken_model
80+
self.scopes = scopes
8081

8182
self.discover = discovery.configure(cache_ttl=signature_cache_ttl)
8283
oidc_discoveries = self.discover.auth_server(
8384
openid_connect_url=self.openid_connect_url
8485
)
86+
scopes_dict = {
87+
scope: "" for scope in self.discover.supported_scopes(oidc_discoveries)
88+
}
8589

8690
self.oidc_scheme = OpenIdConnect(
8791
openIdConnectUrl=openid_connect_url,
8892
auto_error=False,
8993
)
9094
self.password_scheme = OAuth2PasswordBearer(
9195
tokenUrl=self.discover.token_url(oidc_discoveries),
92-
scopes=scopes,
96+
scopes=scopes_dict,
9397
auto_error=False,
9498
)
9599
self.implicit_scheme = OAuth2Facade(
@@ -98,7 +102,7 @@ def __init__(
98102
"authorizationUrl": self.discover.authorization_url(
99103
oidc_discoveries
100104
),
101-
"scopes": scopes,
105+
"scopes": scopes_dict,
102106
}
103107
),
104108
scheme_name="OAuth2ImplicitBearer",
@@ -108,7 +112,7 @@ def __init__(
108112
authorizationUrl=self.discover.authorization_url(oidc_discoveries),
109113
tokenUrl=self.discover.token_url(oidc_discoveries),
110114
# refreshUrl=self.discover.refresh_url(oidc_discoveries),
111-
scopes=scopes,
115+
scopes=scopes_dict,
112116
auto_error=False,
113117
)
114118

@@ -118,7 +122,7 @@ def required(
118122
authorization_credentials: Optional[HTTPAuthorizationCredentials] = Depends(
119123
HTTPBearer()
120124
),
121-
) -> Optional[IDToken]:
125+
) -> IDToken:
122126
"""Validate and parse OIDC ID token against issuer in config.
123127
Note this function caches the signatures and algorithms of the issuing
124128
server for signature_cache_ttl seconds.
@@ -136,11 +140,15 @@ def required(
136140
IDToken validation errors
137141
"""
138142

139-
return self.authenticate_user(
143+
id_token = self.authenticate_user(
140144
security_scopes,
141145
authorization_credentials,
142146
auto_error=True,
143147
)
148+
if id_token is None:
149+
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
150+
else:
151+
return id_token
144152

145153
def optional(
146154
self,
@@ -230,21 +238,21 @@ def authenticate_user(
230238
raise JWTError(
231239
f"""Invalid authorized party "azp": {id_token["azp"]}"""
232240
)
233-
elif type(token_audience) == list and len(token_audience) >= 1:
234-
raise JWTError('Missing authorized party "azp" in IDToken')
241+
elif type(token_audience) == list and len(token_audience) >= 1:
242+
raise JWTError('Missing authorized party "azp" in IDToken')
235243

236244
except (ExpiredSignatureError, JWTError, JWTClaimsError) as error:
237245
raise HTTPException(status_code=401, detail=f"Unauthorized: {error}")
238246

239-
if not set(security_scopes.scopes).issubset(
240-
id_token.get("scope", "").split(" ")
241-
):
242-
if auto_error:
243-
raise HTTPException(
244-
status.HTTP_401_UNAUTHORIZED,
245-
detail=f"""Missing scope token, only have {id_token["scopes"]}""",
246-
)
247-
else:
248-
return None
247+
expected_scopes = set(self.scopes + security_scopes.scopes)
248+
token_scopes = id_token.get("scope", "").split(" ")
249+
if not expected_scopes.issubset(token_scopes):
250+
raise HTTPException(
251+
status.HTTP_401_UNAUTHORIZED,
252+
detail=(
253+
f"Missing scope token, expected {expected_scopes} to be a "
254+
f"subset of received {token_scopes}",
255+
),
256+
)
249257

250258
return self.idtoken_model(**id_token)

fastapi_oidc/discovery.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,15 @@ def get_authorization_url(OIDC_spec: Dict) -> str:
3535
def get_token_url(OIDC_spec: Dict) -> str:
3636
return OIDC_spec["token_endpoint"]
3737

38+
def get_supported_scopes(OIDC_spec: Dict) -> str:
39+
return OIDC_spec["scopes_supported"]
40+
3841
class functions:
3942
auth_server = discover_auth_server
4043
public_keys = get_authentication_server_public_keys
4144
signing_algos = get_signing_algos
4245
authorization_url = get_authorization_url
4346
token_url = get_token_url
47+
supported_scopes = get_supported_scopes
4448

4549
return functions

fastapi_oidc/types.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ class OktaIDToken(IDToken):
5151
class KeycloakIDToken(IDToken):
5252
"""Pydantic Model for the IDToken returned by Keycloak's OIDC implementation."""
5353

54-
auth_time: int
5554
jti: str
5655
name: str
5756
email: str

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,5 +139,6 @@ class functions:
139139
signing_algos = lambda x: x["id_token_signing_alg_values_supported"]
140140
authorization_url = lambda x: x["authorization_endpoint"]
141141
token_url = lambda x: x["token_endpoint"]
142+
supported_scopes = lambda x: x["scopes_supported"]
142143

143144
return lambda *args, **kwargs: functions

0 commit comments

Comments
 (0)