apppy-auth 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ __generated__/
2
+ dist/
3
+ *.egg-info
4
+ .env
5
+ .env.*
6
+ *.env
7
+ !.env.ci
8
+ .file_store/
9
+ *.pid
10
+ .python-version
11
+ *.secrets
12
+ .secrets
13
+ *.tar.gz
14
+ *.test_output/
15
+ .test_output/
16
+ uv.lock
17
+ *.whl
18
+
19
+ # System files
20
+ __pycache__
21
+ .DS_Store
22
+
23
+ # Editor files
24
+ *.sublime-project
25
+ *.sublime-workspace
26
+ .vscode/*
27
+ !.vscode/settings.json
28
+
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: apppy-auth
3
+ Version: 0.1.0
4
+ Summary: Authentication definitions for server development
5
+ Project-URL: Homepage, https://github.com/spals/apppy
6
+ Author: Tim Kral
7
+ License: MIT
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: apppy-env>=0.1.0
12
+ Requires-Dist: apppy-fastql>=0.1.0
13
+ Requires-Dist: apppy-fs>=0.1.0
14
+ Requires-Dist: apppy-logger>=0.1.0
15
+ Requires-Dist: argon2-cffi==25.1.0
16
+ Requires-Dist: authlib==1.6.0
17
+ Requires-Dist: fastapi-another-jwt-auth==0.1.11
18
+ Requires-Dist: fastapi-lifespan-manager==0.1.4
19
+ Requires-Dist: fastapi==0.115.14
20
+ Requires-Dist: itsdangerous==2.2.0
21
+ Requires-Dist: jwcrypto==1.5.6
22
+ Requires-Dist: pyjwt==2.10.1
23
+ Requires-Dist: python-dateutil==2.9.0
File without changes
@@ -0,0 +1,23 @@
1
+ ifndef APPPY_AUTH_MK_INCLUDED
2
+ APPPY_AUTH_MK_INCLUDED := 1
3
+ AUTH_PKG_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
4
+
5
+ .PHONY: auth auth-dev auth/build auth/clean auth/install auth/install-dev
6
+
7
+ auth: auth/clean auth/install
8
+
9
+ auth-dev: auth/clean auth/install-dev
10
+
11
+ auth/build:
12
+ cd $(AUTH_PKG_DIR) && uvx --from build pyproject-build
13
+
14
+ auth/clean:
15
+ cd $(AUTH_PKG_DIR) && rm -rf dist/ *.egg-info .venv
16
+
17
+ auth/install: auth/build
18
+ cd $(AUTH_PKG_DIR) && uv pip install dist/*.whl
19
+
20
+ auth/install-dev:
21
+ cd $(AUTH_PKG_DIR) && uv pip install -e .
22
+
23
+ endif # APPPY_AUTH_MK_INCLUDED
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "apppy-auth"
7
+ version = "0.1.0"
8
+ description = "Authentication definitions for server development"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ authors = [{ name = "Tim Kral" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ ]
17
+ dependencies = [
18
+ "apppy-env>=0.1.0",
19
+ "apppy-fastql>=0.1.0",
20
+ "apppy-fs>=0.1.0",
21
+ "apppy-logger>=0.1.0",
22
+ "argon2-cffi==25.1.0",
23
+ "authlib==1.6.0",
24
+ "fastapi==0.115.14",
25
+ "fastapi-lifespan-manager==0.1.4",
26
+ "fastapi-another-jwt-auth==0.1.11",
27
+ "itsdangerous==2.2.0",
28
+ "jwcrypto==1.5.6",
29
+ "PyJWT==2.10.1",
30
+ "python-dateutil==2.9.0"
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/spals/apppy"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/apppy"]
@@ -0,0 +1,19 @@
1
+ from fastapi import Request
2
+ from fastapi_another_jwt_auth import AuthJWT as JWT
3
+
4
+
5
+ def convert_token_to_jwt(token: str) -> JWT:
6
+ # The JWT infrastructure only deals with http requests
7
+ # and responses so we'll emulate an http request in
8
+ # order to run the JWT parsing
9
+ scope = {
10
+ "type": "http",
11
+ "headers": [(b"authorization", f"Bearer {token}".encode("latin-1"))],
12
+ }
13
+ req = Request(scope=scope)
14
+ return extract_jwt_from_request(req)
15
+
16
+
17
+ def extract_jwt_from_request(request: Request) -> JWT:
18
+ jwt = JWT(req=request, res=None) # type: ignore[invalid-argument-type]
19
+ return jwt
File without changes
@@ -0,0 +1,10 @@
1
+ from apppy.fastql.annotation import fastql_type_error
2
+ from apppy.fastql.errors import GraphQLServerError
3
+
4
+
5
+ @fastql_type_error
6
+ class IllegalJwksPemFileError(GraphQLServerError):
7
+ """Error raised when a JWKS PEM file is not well-formed"""
8
+
9
+ def __init__(self, code: str) -> None:
10
+ super().__init__(code)
@@ -0,0 +1,24 @@
1
+ from apppy.fastql.annotation import fastql_type_error
2
+ from apppy.fastql.errors import GraphQLClientError
3
+
4
+
5
+ @fastql_type_error
6
+ class IllegalNativeOAuthProviderError(GraphQLClientError):
7
+ """Error raised when a non-native OAuth provider is treated as a native one"""
8
+
9
+ provider: str
10
+
11
+ def __init__(self, provider: str) -> None:
12
+ super().__init__("illegal_native_oauth_provider")
13
+ self.provider = provider
14
+
15
+
16
+ @fastql_type_error
17
+ class UnknownOAuthProviderError(GraphQLClientError):
18
+ """Error raised when a client has provided an unknown OAuth client name"""
19
+
20
+ provider: str
21
+
22
+ def __init__(self, provider: str) -> None:
23
+ super().__init__("unknown_oauth_provider")
24
+ self.provider = provider
@@ -0,0 +1,34 @@
1
+ from apppy.fastql.annotation import fastql_type_error
2
+ from apppy.fastql.errors import GraphQLClientError, GraphQLServerError
3
+
4
+
5
+ @fastql_type_error
6
+ class ServiceAuthenticaionDisabledError(GraphQLServerError):
7
+ """Error raised when a service trying to authenticate but service authentication is disabled"""
8
+
9
+ def __init__(self) -> None:
10
+ super().__init__("service_authenticaion_disabled")
11
+
12
+
13
+ @fastql_type_error
14
+ class ServiceKeyAlgorithmMissingError(GraphQLClientError):
15
+ """Error raised when a service trying to authenticate does not include an algorithm header"""
16
+
17
+ def __init__(self) -> None:
18
+ super().__init__("service_key_algorithm_missing")
19
+
20
+
21
+ @fastql_type_error
22
+ class ServiceKeyMissingError(GraphQLServerError):
23
+ """Error raised when a service trying to authenticate does not have a registered public key"""
24
+
25
+ def __init__(self) -> None:
26
+ super().__init__("service_key_missing")
27
+
28
+
29
+ @fastql_type_error
30
+ class ServiceUnknownError(GraphQLClientError):
31
+ """Error raised when a service trying to authenticate cannot be found"""
32
+
33
+ def __init__(self) -> None:
34
+ super().__init__("service_unknown")
@@ -0,0 +1,69 @@
1
+ from apppy.fastql.annotation import fastql_type_error
2
+ from apppy.fastql.errors import GraphQLClientError, GraphQLServerError
3
+
4
+
5
+ @fastql_type_error
6
+ class UserSessionRefreshMissingSessionError(GraphQLClientError):
7
+ """Error raised when a user session refresh is attempted against a missing session"""
8
+
9
+ def __init__(self) -> None:
10
+ super().__init__("user_session_refresh_missing_session")
11
+
12
+
13
+ @fastql_type_error
14
+ class UserSignInInvalidCredentialsError(GraphQLClientError):
15
+ """Error raised when a user has provided invalid credentials for sign in"""
16
+
17
+ def __init__(self) -> None:
18
+ super().__init__("user_sign_in_invalid_credentials")
19
+
20
+
21
+ @fastql_type_error
22
+ class UserSignInServerError(GraphQLServerError):
23
+ """Error raised when there's a user sign in error on the server side"""
24
+
25
+ def __init__(self, code: str) -> None:
26
+ super().__init__(code)
27
+
28
+
29
+ @fastql_type_error
30
+ class UserSignOutInvalidScopeError(GraphQLClientError):
31
+ """Error raised when a user has provided an invalid scope for sign out"""
32
+
33
+ scope: str
34
+
35
+ def __init__(self, scope: str) -> None:
36
+ super().__init__("user_sign_out_invalid_scope")
37
+ self.scope = scope
38
+
39
+
40
+ @fastql_type_error
41
+ class UserSignOutServerError(GraphQLServerError):
42
+ """Error raised when there's a user sign out error on the server side"""
43
+
44
+ def __init__(self, code: str) -> None:
45
+ super().__init__(code)
46
+
47
+
48
+ @fastql_type_error
49
+ class UserSignUpInvalidCredentialsError(GraphQLClientError):
50
+ """Error raised when a user has provided invalid credentials for sign up"""
51
+
52
+ def __init__(self) -> None:
53
+ super().__init__("user_sign_up_invalid_credentials")
54
+
55
+
56
+ @fastql_type_error
57
+ class UserSignUpTooManyRetriesError(GraphQLServerError):
58
+ """Error raised when there have been too many user sign up attempts"""
59
+
60
+ def __init__(self) -> None:
61
+ super().__init__("user_sign_up_too_many_retries")
62
+
63
+
64
+ @fastql_type_error
65
+ class UserSignUpServerError(GraphQLServerError):
66
+ """Error raised when there's a user sign up error on the server side"""
67
+
68
+ def __init__(self, code: str) -> None:
69
+ super().__init__(code)
@@ -0,0 +1,255 @@
1
+ import datetime
2
+ import re
3
+ from dataclasses import dataclass
4
+
5
+ from fastapi_lifespan_manager import LifespanManager
6
+ from jwcrypto.jwk import JWK
7
+ from pydantic import Field
8
+
9
+ from apppy.auth.errors.jwks import IllegalJwksPemFileError
10
+ from apppy.auth.errors.service import (
11
+ ServiceAuthenticaionDisabledError,
12
+ ServiceKeyMissingError,
13
+ ServiceUnknownError,
14
+ )
15
+ from apppy.env import EnvSettings
16
+ from apppy.fs import FileSystem, FileUrl
17
+ from apppy.logger import WithLogger
18
+
19
+
20
+ @dataclass
21
+ class JwkPemFile:
22
+ file_url: FileUrl
23
+
24
+ is_public: bool
25
+ service_name: str
26
+ version: int
27
+ generated_at: datetime.datetime
28
+
29
+ @property
30
+ def file_ext(self) -> str:
31
+ return "pub.pem" if self.is_public else "key.pem"
32
+
33
+ @property
34
+ def kid(self) -> str:
35
+ return f"{self.service_name}.v{self.version}"
36
+
37
+ @staticmethod
38
+ def file_name_private(service_name: str, version: int, generated_at: datetime.datetime) -> str:
39
+ generated_ts = generated_at.strftime("%Y%m%d%H%M%S")
40
+ return f"{service_name}.v{version}.{generated_ts}.key.pem"
41
+
42
+ @staticmethod
43
+ def file_name_public(service_name: str, version: int, generated_at: datetime.datetime) -> str:
44
+ generated_ts = generated_at.strftime("%Y%m%d%H%M%S")
45
+ return f"{service_name}.v{version}.{generated_ts}.pub.pem"
46
+
47
+ @staticmethod
48
+ def from_file_url(file_url: FileUrl) -> "JwkPemFile":
49
+ if file_url.file_name is None:
50
+ raise IllegalJwksPemFileError("unavailable_pem_file_name")
51
+
52
+ service_name, version, generated_at = JwkPemFile._parse_file_name(file_url.file_name)
53
+ return JwkPemFile(
54
+ file_url=file_url,
55
+ is_public=file_url.file_name.endswith("pub.pem"),
56
+ service_name=service_name,
57
+ version=version,
58
+ generated_at=generated_at,
59
+ )
60
+
61
+ @staticmethod
62
+ def _parse_file_name(file_name: str) -> tuple[str, int, datetime.datetime]:
63
+ if not file_name.endswith(".pub.pem") and not file_name.endswith(".key.pem"):
64
+ raise IllegalJwksPemFileError("unknown_pem_file_extension")
65
+
66
+ pattern = (
67
+ r"^(?P<service>[a-zA-Z0-9_-]+)\.v(?P<version>\d+)\.(?P<ts>\d{14})\.(pub|key)\.pem$"
68
+ )
69
+ m = re.match(pattern, file_name)
70
+ if not m:
71
+ raise ValueError(f"Invalid filename format: {file_name}")
72
+
73
+ service_name = m.group("service")
74
+ version = int(m.group("version"))
75
+ ts_str = m.group("ts")
76
+ generated_at = datetime.datetime.strptime(ts_str, "%Y%m%d%H%M%S")
77
+
78
+ return (service_name, version, generated_at)
79
+
80
+
81
+ @dataclass
82
+ class JwkInfo:
83
+ key: dict
84
+ jwk: JWK
85
+ pem: JwkPemFile
86
+
87
+
88
+ class JwksAuthStorageSettings(EnvSettings):
89
+ # NOTE: Some of these configuration values are general to all authentication
90
+ # so those use the APP_AUTH prefix. Configuration values specific to jwks use
91
+ # the APP_JWKS_AUTH prefix.
92
+
93
+ # Control whether JWKS authenication is available.
94
+ # If not available (the default), JwksAuth will not attempt to read
95
+ # any files from the FileSystem.
96
+ # Note that this is useful for integration tests. Some integration tests
97
+ # run with an encrypted FileSystem and some without. So they cannot share
98
+ # pem files.
99
+ jwks_auth_enabled: bool = Field(alias="APP_AUTH_SERVICES_ENABLED", default=False)
100
+ jwks_auth_fs_partition: str = Field(alias="APP_AUTH_FS_PARTITION", default="auth")
101
+
102
+ jwks_auth_root_dir: str = Field(alias="APP_JWKS_AUTH_ROOT_DIR", default=".jwks")
103
+
104
+
105
+ class JwksAuthStorage(WithLogger):
106
+ def __init__(
107
+ self, settings: JwksAuthStorageSettings, lifespan: LifespanManager, fs: FileSystem
108
+ ):
109
+ self._settings = settings
110
+ self._fs = fs
111
+
112
+ self._jwks_root_file_url: FileUrl = self._fs.new_file_url_internal(
113
+ protocol=self._fs.settings.default_protocol,
114
+ partition=settings.jwks_auth_fs_partition,
115
+ directory=settings.jwks_auth_root_dir,
116
+ file_name=None,
117
+ )
118
+ self._jwks_full_cache: dict[str, list[JwkInfo]] = {}
119
+ self._jwks_version_cache: dict[str, int] = {}
120
+
121
+ self._keys: list[dict] = []
122
+ self._keys_dict = {"keys": self._keys}
123
+ if self._settings.jwks_auth_enabled is True:
124
+ lifespan.add(self.rebuild_caches)
125
+
126
+ def _clear_cache(self):
127
+ self._jwks_full_cache.clear()
128
+ self._jwks_version_cache.clear()
129
+ self._keys.clear()
130
+
131
+ def add_jwk(self, pem_file_url: FileUrl) -> JwkInfo:
132
+ if not self._fs.exists(self._jwks_root_file_url):
133
+ self._fs.makedir(self._jwks_root_file_url, create_parents=True)
134
+
135
+ pem_file: JwkPemFile = JwkPemFile.from_file_url(pem_file_url)
136
+ pem_file_bytes: bytes = self._fs.read_bytes(pem_file_url)
137
+ jwk_: JWK = JWK.from_pem(pem_file_bytes)
138
+ key = jwk_.export_public(as_dict=True)
139
+ key["use"] = "sig"
140
+ key["alg"] = "EdDSA"
141
+ key["kid"] = pem_file.kid
142
+
143
+ if pem_file.service_name not in self._jwks_full_cache:
144
+ self._jwks_full_cache[pem_file.service_name] = []
145
+
146
+ jwk_info = JwkInfo(key=key, jwk=jwk_, pem=pem_file)
147
+ self._jwks_full_cache[pem_file.service_name].append(jwk_info)
148
+
149
+ if (
150
+ pem_file.service_name not in self._jwks_version_cache
151
+ or self._jwks_version_cache[pem_file.service_name] < pem_file.version
152
+ ):
153
+ self._jwks_version_cache[pem_file.service_name] = pem_file.version
154
+
155
+ self._keys.append(key)
156
+ return jwk_info
157
+
158
+ def all_services(self) -> dict[str, list[JwkPemFile]]:
159
+ if self._settings.jwks_auth_enabled is False:
160
+ raise ServiceAuthenticaionDisabledError()
161
+
162
+ return {s: [j.pem for j in jwk_infos] for s, jwk_infos in self._jwks_full_cache.items()}
163
+
164
+ def clear_all_jwks(self) -> None:
165
+ if self._fs.exists(self._jwks_root_file_url):
166
+ self._fs.rm(self._jwks_root_file_url, recursive=True)
167
+
168
+ self._clear_cache()
169
+
170
+ def current_service_version(self, service_name: str) -> int | None:
171
+ if self._settings.jwks_auth_enabled is False:
172
+ raise ServiceAuthenticaionDisabledError()
173
+
174
+ return self._jwks_version_cache.get(service_name)
175
+
176
+ def get_jwk(self, kid: str) -> JwkInfo:
177
+ if self._settings.jwks_auth_enabled is False:
178
+ raise ServiceAuthenticaionDisabledError()
179
+
180
+ service_name = kid.split(".")[0]
181
+ if service_name not in self._jwks_full_cache:
182
+ raise ServiceUnknownError()
183
+
184
+ jwk_info = next(
185
+ filter(
186
+ lambda e: e.pem.kid == kid,
187
+ self._jwks_full_cache[service_name],
188
+ ),
189
+ None,
190
+ )
191
+ if jwk_info is None:
192
+ raise ServiceKeyMissingError()
193
+
194
+ return jwk_info
195
+
196
+ @property
197
+ def is_enabled(self) -> bool:
198
+ return self._settings.jwks_auth_enabled
199
+
200
+ @property
201
+ def keys_dict(self) -> dict:
202
+ if self._settings.jwks_auth_enabled is False:
203
+ raise ServiceAuthenticaionDisabledError()
204
+
205
+ return self._keys_dict
206
+
207
+ async def rebuild_caches(self):
208
+ if self._settings.jwks_auth_enabled is False:
209
+ raise ServiceAuthenticaionDisabledError()
210
+
211
+ if not self._fs.exists(self._jwks_root_file_url):
212
+ self._fs.makedir(self._jwks_root_file_url, create_parents=True)
213
+
214
+ self._clear_cache()
215
+
216
+ for service_dir in self._fs.ls(self._jwks_root_file_url):
217
+ service_dir_url = self._fs.parse_file_url(service_dir["url"])
218
+ if self._fs.isdir(service_dir_url):
219
+ self._jwks_full_cache[service_dir_url.directory] = []
220
+ for pem_file in self._fs.ls(service_dir_url):
221
+ pem_file_url = self._fs.parse_file_url(pem_file["url"])
222
+ if self._fs.isfile(pem_file_url):
223
+ self.add_jwk(pem_file_url)
224
+
225
+ yield {
226
+ "jwks_full_cache": self._jwks_full_cache,
227
+ "jwks_version_cache": self._jwks_version_cache,
228
+ "keys": self._keys,
229
+ }
230
+
231
+ # IMPORTANT NOTE: Each server instance keeps an in-memory cache of
232
+ # all jwks records. Thus if you are running multiple instances and
233
+ # you write a new public key (e.g. due to regular rotation maintenance)
234
+ # you will need to ensure that all server instances are rebooted.
235
+ async def write_public_key(self, service_name: str, key_bytes: bytes) -> JwkInfo:
236
+ if self._settings.jwks_auth_enabled is False:
237
+ raise ServiceAuthenticaionDisabledError()
238
+
239
+ version = (
240
+ self._jwks_version_cache[service_name] + 1
241
+ if service_name in self._jwks_version_cache
242
+ else 0
243
+ )
244
+ generated_at = datetime.datetime.now(datetime.UTC)
245
+
246
+ pem_file_url: FileUrl = self._jwks_root_file_url.join(
247
+ directory=service_name,
248
+ file_name=JwkPemFile.file_name_public(service_name, version, generated_at),
249
+ )
250
+ service_dir_url = pem_file_url.parent()
251
+ if not self._fs.exists(service_dir_url):
252
+ self._fs.makedir(url=pem_file_url.parent(), create_parents=True)
253
+
254
+ pem_file_url, _ = await self._fs.write_bytes(pem_file_url, key_bytes)
255
+ return self.add_jwk(pem_file_url)
@@ -0,0 +1,21 @@
1
+ import datetime
2
+
3
+ from apppy.auth.jwks import JwkPemFile
4
+
5
+
6
+ def test_jwk_parse_file_name_private():
7
+ service_name, version, generated_at = JwkPemFile._parse_file_name(
8
+ "test_jwk_pem_file_public.v0.20250905215639.key.pem"
9
+ )
10
+ assert service_name == "test_jwk_pem_file_public"
11
+ assert version == 0
12
+ assert generated_at == datetime.datetime(2025, 9, 5, 21, 56, 39, 0)
13
+
14
+
15
+ def test_jwk_parse_file_name_public():
16
+ service_name, version, generated_at = JwkPemFile._parse_file_name(
17
+ "test_jwk_pem_file_public.v0.20250905215639.pub.pem"
18
+ )
19
+ assert service_name == "test_jwk_pem_file_public"
20
+ assert version == 0
21
+ assert generated_at == datetime.datetime(2025, 9, 5, 21, 56, 39, 0)
@@ -0,0 +1,200 @@
1
+ import contextvars
2
+ import datetime
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Any, Literal
6
+
7
+ from fastapi import HTTPException, Request
8
+ from fastapi_another_jwt_auth import AuthJWT as JWT
9
+ from pydantic import Field
10
+
11
+ from apppy.auth import convert_token_to_jwt, extract_jwt_from_request
12
+ from apppy.env import EnvSettings
13
+ from apppy.fastql.errors import GraphQLClientError, GraphQLError, GraphQLServerError
14
+ from apppy.fastql.permissions import GraphQLPermission
15
+ from apppy.logger import WithLogger
16
+
17
+ JwtAuthSubjectType = Literal["user", "service"]
18
+
19
+ # Define a context variable per key
20
+ _current_auth_context: contextvars.ContextVar["JwtAuthContext"] = contextvars.ContextVar(
21
+ "_current_auth_context"
22
+ )
23
+
24
+ # This breaks the typically logger pattern
25
+ # but logging is needed in static methods
26
+ _logger = logging.getLogger("apppy.auth.jwt.JwtAuthContext")
27
+
28
+
29
+ class JwtAuthSettings(EnvSettings):
30
+ # The names here must match the names found in
31
+ # fastapi_another_jwt_auth.AuthConfig.load_config
32
+ authjwt_decode_audience: str = Field(
33
+ alias="APP_JWT_AUTH_DECODE_AUDIENCE", default="authenticated"
34
+ )
35
+
36
+ authjwt_encode_issuer: str = Field(alias="APP_JWT_AUTH_ENCODE_ISSUER")
37
+ authjwt_secret_key: str = Field(alias="APP_JWT_AUTH_SECRET_KEY", exclude=True)
38
+
39
+
40
+ @dataclass
41
+ class JwtAuthContext(WithLogger):
42
+ # In some cases we'll encounter an error
43
+ # while preprocessing a JwtAuthContext. The classic
44
+ # example of this is attempting a service authentication
45
+ # when service authentication is disabled. For those cases,
46
+ # we'll store the preprocessing error code here so that we
47
+ # can return the appropriate error type to the client.
48
+ preprocessing_error: GraphQLError | None = None
49
+
50
+ has_token: bool = False
51
+ is_token_well_formed: bool = False
52
+ expires_at: int = 0
53
+ issued_at: int = 0
54
+
55
+ auth_session_id: str | None = None
56
+ auth_subject_id: str | None = None # user_id or service_id
57
+ auth_subject_type: JwtAuthSubjectType | None = None
58
+
59
+ iam_enabled: bool = False
60
+ iam_revoked_at: datetime.datetime | None = None
61
+ iam_role: str | None = None
62
+ iam_scopes: list[str] | None = None
63
+
64
+ raw_claims: dict[str, Any] | None = None
65
+
66
+ def check_graphql_permissions(self, *permissions: GraphQLPermission) -> "JwtAuthContext":
67
+ for perm in permissions:
68
+ if not perm.has_permission(source=None, info=None, auth_ctx=self): # type: ignore[arg-type]
69
+ raise perm.graphql_client_error_class(*perm.graphql_client_error_args()) # type: ignore[missing-argument]
70
+
71
+ return self
72
+
73
+ def check_http_permissions(self, *permissions: GraphQLPermission) -> "JwtAuthContext":
74
+ # Here we'll get the context for the appropriate GraphQL permissions
75
+ # but then raise an HTTP exception rather than a typed GraphQL exception
76
+ for perm in permissions:
77
+ try:
78
+ if not perm.has_permission(source=None, info=None, auth_ctx=self): # type: ignore[arg-type]
79
+ raise HTTPException(status_code=401)
80
+ except GraphQLClientError as e:
81
+ raise HTTPException(status_code=401) from e
82
+ except GraphQLServerError as e:
83
+ raise HTTPException(status_code=500) from e
84
+
85
+ return self
86
+
87
+ @staticmethod
88
+ def current_auth_context() -> "JwtAuthContext":
89
+ return _current_auth_context.get()
90
+
91
+ @staticmethod
92
+ def set_current_auth_context(auth_ctx: "JwtAuthContext") -> None:
93
+ _current_auth_context.set(auth_ctx)
94
+
95
+ @staticmethod
96
+ def _create_service_context(claims) -> "JwtAuthContext":
97
+ if claims is None:
98
+ _logger.warning("No claims found in service JWT")
99
+ return JwtAuthContext(has_token=False)
100
+ elif "service_metadata" not in claims:
101
+ _logger.warning("No service_metadata found in service JWT.")
102
+ return JwtAuthContext(has_token=True, is_token_well_formed=False)
103
+
104
+ service_metadata: dict[str, Any] = claims["service_metadata"] # type: ignore[invalid-assignment]
105
+ iam_metadata: dict[str, Any] = service_metadata.get("iam_metadata", {})
106
+
107
+ return JwtAuthContext(
108
+ has_token=True,
109
+ is_token_well_formed=True,
110
+ expires_at=claims["exp"], # type: ignore[invalid-argument-type]
111
+ issued_at=claims["iat"], # type: ignore[invalid-argument-type]
112
+ auth_session_id=None,
113
+ auth_subject_id=service_metadata.get("service_name"),
114
+ auth_subject_type="service",
115
+ iam_enabled=True,
116
+ iam_revoked_at=None,
117
+ iam_role=iam_metadata.get("role"),
118
+ iam_scopes=iam_metadata.get("scopes", []),
119
+ raw_claims=claims,
120
+ )
121
+
122
+ @staticmethod
123
+ def _create_user_context(claims) -> "JwtAuthContext":
124
+ if claims is None:
125
+ _logger.warning("No claims found in user JWT")
126
+ return JwtAuthContext(has_token=False)
127
+ elif "user_metadata" not in claims:
128
+ _logger.warning("No user_metadata found in user JWT.")
129
+ return JwtAuthContext(has_token=True, is_token_well_formed=False)
130
+
131
+ user_metadata: dict[str, Any] = claims["user_metadata"] # type: ignore[invalid-assignment]
132
+ iam_metadata: dict[str, Any] = user_metadata.get("iam_metadata", {})
133
+ return JwtAuthContext(
134
+ has_token=True,
135
+ is_token_well_formed=True,
136
+ expires_at=claims["exp"], # type: ignore[invalid-argument-type]
137
+ issued_at=claims["iat"], # type: ignore[invalid-argument-type]
138
+ auth_session_id=claims.get("session_id"), # type: ignore[invalid-argument-type]
139
+ auth_subject_id=user_metadata.get("subject_id"),
140
+ auth_subject_type="user",
141
+ iam_enabled=iam_metadata.get("enabled", False),
142
+ iam_revoked_at=iam_metadata.get("revoked_at"),
143
+ iam_role=iam_metadata.get("role"),
144
+ iam_scopes=iam_metadata.get("scopes", []),
145
+ raw_claims=claims,
146
+ )
147
+
148
+ @staticmethod
149
+ def from_jwt(jwt_: JWT) -> "JwtAuthContext":
150
+ if not jwt_._token:
151
+ return JwtAuthContext(has_token=False)
152
+
153
+ claims = jwt_.get_raw_jwt()
154
+ if claims is None:
155
+ _logger.warning("No claims found in JWT")
156
+ return JwtAuthContext(has_token=False)
157
+
158
+ if "user_metadata" in claims:
159
+ return JwtAuthContext._create_user_context(claims)
160
+ elif "service_metadata" in claims:
161
+ return JwtAuthContext._create_service_context(claims)
162
+
163
+ _logger.warning(
164
+ "No user_metadata nor kid found in JWT. We do not know if this is a user or a service"
165
+ )
166
+ return JwtAuthContext(has_token=True, is_token_well_formed=False)
167
+
168
+ @staticmethod
169
+ def from_service_request(
170
+ request: Request,
171
+ algorithm: str,
172
+ public_key: str,
173
+ ) -> "JwtAuthContext":
174
+ jwt_ = extract_jwt_from_request(request)
175
+ jwt_._algorithm = algorithm
176
+ jwt_._public_key = public_key
177
+
178
+ claims = jwt_.get_raw_jwt()
179
+ return JwtAuthContext._create_service_context(claims)
180
+
181
+ @staticmethod
182
+ def from_user_request(request: Request) -> "JwtAuthContext":
183
+ jwt_ = extract_jwt_from_request(request)
184
+
185
+ claims = jwt_.get_raw_jwt()
186
+ return JwtAuthContext._create_user_context(claims)
187
+
188
+ @staticmethod
189
+ def from_token(token: str) -> "JwtAuthContext":
190
+ jwt_: JWT = convert_token_to_jwt(token)
191
+ return JwtAuthContext.from_jwt(jwt_)
192
+
193
+ @staticmethod
194
+ def peek(request: Request) -> dict | None:
195
+ jwt_: JWT = extract_jwt_from_request(request)
196
+ if jwt_._token is None:
197
+ return None
198
+
199
+ headers = jwt_.get_unverified_jwt_headers()
200
+ return headers
@@ -0,0 +1,160 @@
1
+ import datetime
2
+
3
+ from apppy.auth.jwt import JwtAuthContext
4
+
5
+
6
+ class FakeJWT:
7
+ """
8
+ Minimal stub for the JWT class used by JwtAuthContext.
9
+ - Accepts _token to simulate "has token" vs not.
10
+ - Returns the provided claims from get_raw_jwt().
11
+ """
12
+
13
+ def __init__(self, *, _token=None, claims=None, **kwargs):
14
+ self._token = _token
15
+ self._claims = claims
16
+
17
+ def get_raw_jwt(self):
18
+ return self._claims
19
+
20
+
21
+ def _claims_base(exp=1111111111, iat=1111110000, **extra):
22
+ c = {"exp": exp, "iat": iat}
23
+ c.update(extra)
24
+ return c
25
+
26
+
27
+ def test_jwt_empty():
28
+ ctx = JwtAuthContext()
29
+ assert ctx.has_token is False
30
+ assert ctx.is_token_well_formed is False
31
+ assert ctx.expires_at == 0
32
+ assert ctx.issued_at == 0
33
+ # sanity: optional fields default to None
34
+ assert ctx.auth_session_id is None
35
+ assert ctx.auth_subject_id is None
36
+ assert ctx.auth_subject_type is None
37
+ assert ctx.iam_enabled is False
38
+
39
+ assert ctx.iam_revoked_at is None
40
+ assert ctx.iam_role is None
41
+ assert ctx.iam_scopes is None
42
+
43
+ assert ctx.raw_claims is None
44
+
45
+
46
+ def test_jwt_is_unauthenticated_when_no_token():
47
+ jwt = FakeJWT(_token=None, claims=None)
48
+ ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
49
+ assert ctx.has_token is False
50
+ assert ctx.is_token_well_formed is False
51
+ assert ctx.iam_enabled is False
52
+
53
+
54
+ def test_jwt_is_unauthenticated_when_no_claims():
55
+ jwt = FakeJWT(_token="token", claims=None)
56
+ ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
57
+ assert ctx.has_token is False
58
+ assert ctx.is_token_well_formed is False
59
+ assert ctx.iam_enabled is False
60
+
61
+
62
+ def test_jwt_is_unauthenticated_when_missing_user_metadata():
63
+ claims = _claims_base() # no "user_metadata" key
64
+ jwt = FakeJWT(_token="token", claims=claims)
65
+ ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
66
+ assert ctx.has_token is True
67
+ assert ctx.is_token_well_formed is False
68
+ assert ctx.iam_enabled is False
69
+
70
+
71
+ def test_service_jwt_is_unauthenticated_when_iam_is_disabled():
72
+ claims = _claims_base(
73
+ user_metadata={
74
+ "subject_type": "service",
75
+ "iam_metadata": {
76
+ "enabled": False,
77
+ },
78
+ }
79
+ )
80
+ jwt = FakeJWT(_token="token", claims=claims)
81
+ ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
82
+ assert ctx.has_token is True
83
+ assert ctx.is_token_well_formed is True
84
+ assert ctx.iam_enabled is False
85
+
86
+
87
+ def test_service_jwt_is_unauthenticated_when_iam_is_revoked():
88
+ claims = _claims_base(
89
+ user_metadata={
90
+ "subject_type": "service",
91
+ "iam_metadata": {"enabled": True, "revoked_at": datetime.datetime.now()},
92
+ }
93
+ )
94
+ jwt = FakeJWT(_token="token", claims=claims)
95
+ ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
96
+ assert ctx.has_token is True
97
+ assert ctx.is_token_well_formed is True
98
+ assert ctx.iam_enabled is True
99
+ assert ctx.iam_revoked_at is not None
100
+
101
+
102
+ def test_service_jwt_subject_type():
103
+ claims = _claims_base(
104
+ service_metadata={
105
+ "subject_type": "service",
106
+ "iam_metadata": {
107
+ "enabled": True,
108
+ },
109
+ }
110
+ )
111
+ jwt = FakeJWT(_token="token", claims=claims)
112
+
113
+ ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
114
+ assert ctx.auth_subject_type == "service"
115
+
116
+
117
+ def test_user_jwt_is_unauthenticated_when_iam_is_disabled():
118
+ claims = _claims_base(
119
+ user_metadata={
120
+ "subject_type": "user",
121
+ "iam_metadata": {
122
+ "enabled": False,
123
+ },
124
+ }
125
+ )
126
+ jwt = FakeJWT(_token="token", claims=claims)
127
+ ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
128
+ assert ctx.has_token is True
129
+ assert ctx.is_token_well_formed is True
130
+ assert ctx.iam_enabled is False
131
+
132
+
133
+ def test_user_jwt_is_unauthenticated_when_iam_is_revoked():
134
+ claims = _claims_base(
135
+ user_metadata={
136
+ "subject_type": "user",
137
+ "iam_metadata": {"enabled": True, "revoked_at": datetime.datetime.now()},
138
+ }
139
+ )
140
+ jwt = FakeJWT(_token="token", claims=claims)
141
+ ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
142
+ assert ctx.has_token is True
143
+ assert ctx.is_token_well_formed is True
144
+ assert ctx.iam_enabled is True
145
+ assert ctx.iam_revoked_at is not None
146
+
147
+
148
+ def test_user_jwt_subject_type():
149
+ claims = _claims_base(
150
+ user_metadata={
151
+ "subject_type": "user",
152
+ "iam_metadata": {
153
+ "enabled": True,
154
+ },
155
+ }
156
+ )
157
+ jwt = FakeJWT(_token="token", claims=claims)
158
+
159
+ ctx = JwtAuthContext.from_jwt(jwt) # type: ignore[invalid-argument-type]
160
+ assert ctx.auth_subject_type == "user"
@@ -0,0 +1,31 @@
1
+ from authlib.integrations.starlette_client import OAuth
2
+
3
+ from apppy.auth.errors.oauth import UnknownOAuthProviderError
4
+ from apppy.env import EnvSettings
5
+ from apppy.logger import WithLogger
6
+
7
+
8
+ class OAuthRegistrySettings(EnvSettings):
9
+ pass
10
+
11
+
12
+ class OAuthRegistry(OAuth, WithLogger):
13
+ """
14
+ Wrapper about the Starlette OAuth integration to allow for
15
+ any custom settings or logic to be added.
16
+ """
17
+
18
+ def __init__(self, settings: OAuthRegistrySettings) -> None:
19
+ super().__init__()
20
+ self._settings = settings
21
+
22
+ def load_client(self, provider: str) -> OAuth:
23
+ oauth_client = self.create_client(provider)
24
+ if oauth_client is None:
25
+ raise UnknownOAuthProviderError(provider)
26
+
27
+ return oauth_client
28
+
29
+ def register(self, name, overwrite=False, **kwargs):
30
+ self._logger.info("Registering OAuth provider", extra={"provider": name})
31
+ super().register(name, overwrite=overwrite, **kwargs)
@@ -0,0 +1,281 @@
1
+ import datetime
2
+ from collections.abc import Sequence
3
+ from typing import Any
4
+
5
+ from apppy.auth.jwt import JwtAuthContext
6
+ from apppy.fastql.annotation import fastql_type_error
7
+ from apppy.fastql.errors import GraphQLClientError, GraphQLServerError
8
+ from apppy.fastql.permissions import GraphQLPermission
9
+ from apppy.logger import WithLogger
10
+
11
+
12
+ def _check_preprocessing_errors(permission: GraphQLPermission, auth_ctx: JwtAuthContext) -> None:
13
+ if auth_ctx.preprocessing_error is not None:
14
+ if isinstance(auth_ctx.preprocessing_error, GraphQLClientError):
15
+ permission._error_code = auth_ctx.preprocessing_error.code # type: ignore[attr-defined]
16
+ raise permission.graphql_client_error_class(*permission.graphql_client_error_args())
17
+ else:
18
+ raise permission.graphql_server_error_class(auth_ctx.preprocessing_error.code)
19
+
20
+
21
+ def _extract_auth_context(permission: GraphQLPermission, info, **kwargs) -> JwtAuthContext:
22
+ auth_ctx: JwtAuthContext = kwargs.get("auth_ctx") # type: ignore[assignment]
23
+ if auth_ctx is not None:
24
+ return auth_ctx
25
+
26
+ if not info:
27
+ permission._logger.error(
28
+ "No context or info provided while attempting to check permission",
29
+ extra={"permission": type(permission).__name__},
30
+ )
31
+ raise permission.graphql_server_error_class("missing_context_and_info")
32
+
33
+ return info.context.auth
34
+
35
+
36
+ @fastql_type_error
37
+ class AuthenticationServerError(GraphQLServerError):
38
+ """Error raised when authentication fails due to an error on the server side"""
39
+
40
+ def __init__(self, code) -> None:
41
+ super().__init__(code)
42
+
43
+
44
+ @fastql_type_error
45
+ class AuthenticatedServiceOrUserRequiredError(GraphQLClientError):
46
+ """Error raised when authentication is required but it is missing"""
47
+
48
+ def __init__(self, code) -> None:
49
+ super().__init__(code)
50
+
51
+
52
+ class IsServiceOrUser(GraphQLPermission, WithLogger):
53
+ graphql_client_error_class = AuthenticatedServiceOrUserRequiredError
54
+ graphql_server_error_class = AuthenticationServerError
55
+
56
+ def __init__(self):
57
+ self._error_code: str | None = None
58
+
59
+ def has_permission(self, source, info, **kwargs) -> bool:
60
+ auth_ctx: JwtAuthContext = _extract_auth_context(self, info, **kwargs)
61
+ _check_preprocessing_errors(self, auth_ctx)
62
+
63
+ is_authenticated, self._error_code = IsServiceOrUser.is_context_authenticated(auth_ctx)
64
+ return is_authenticated
65
+
66
+ @staticmethod
67
+ def is_context_authenticated(auth_ctx: JwtAuthContext) -> tuple[bool, str | None]:
68
+ if not auth_ctx.has_token:
69
+ return (False, "token_is_missing")
70
+ if not auth_ctx.is_token_well_formed:
71
+ return (False, "token_is_not_well_formed")
72
+
73
+ now = int(datetime.datetime.now().timestamp())
74
+ if now > auth_ctx.expires_at:
75
+ return (False, "token_is_expired")
76
+ if not auth_ctx.iam_enabled:
77
+ return (False, "iam_is_disabled")
78
+ if auth_ctx.iam_revoked_at is not None:
79
+ return (False, "iam_is_revoked")
80
+
81
+ return (True, None)
82
+
83
+ def graphql_client_error_args(self) -> tuple[Any, ...]:
84
+ return (self._error_code,)
85
+
86
+
87
+ @fastql_type_error
88
+ class AuthenticatedUserRequiredError(GraphQLClientError):
89
+ """Error raised when a user authentication is required but it is missing"""
90
+
91
+ def __init__(self, code: str) -> None:
92
+ super().__init__(code)
93
+
94
+
95
+ class IsUser(GraphQLPermission, WithLogger):
96
+ graphql_client_error_class = AuthenticatedUserRequiredError
97
+ graphql_server_error_class = AuthenticationServerError
98
+
99
+ def __init__(self):
100
+ self._error_code: str | None = None
101
+
102
+ def has_permission(self, source, info, **kwargs) -> bool:
103
+ auth_ctx: JwtAuthContext = _extract_auth_context(self, info, **kwargs)
104
+ _check_preprocessing_errors(self, auth_ctx)
105
+
106
+ is_authenticated, self._error_code = IsUser.is_context_user_authenticated(auth_ctx)
107
+ return is_authenticated
108
+
109
+ @staticmethod
110
+ def is_context_user_authenticated(auth_ctx: JwtAuthContext) -> tuple[bool, str | None]:
111
+ is_authenticated, error_code = IsServiceOrUser.is_context_authenticated(auth_ctx)
112
+ if not is_authenticated:
113
+ return (is_authenticated, error_code)
114
+
115
+ if auth_ctx.auth_subject_type != "user":
116
+ return (False, "not_authenticated_as_user")
117
+
118
+ return (True, None)
119
+
120
+ def graphql_client_error_args(self) -> tuple[Any, ...]:
121
+ return (self._error_code,)
122
+
123
+
124
+ @fastql_type_error
125
+ class AuthenticatedServiceRequiredError(GraphQLClientError):
126
+ """Error raised when a service authentication is required but it is missing"""
127
+
128
+ def __init__(self, code: str) -> None:
129
+ super().__init__(code)
130
+
131
+
132
+ class IsService(GraphQLPermission):
133
+ graphql_client_error_class = AuthenticatedServiceRequiredError
134
+ graphql_server_error_class = AuthenticationServerError
135
+
136
+ def __init__(self):
137
+ self._error_code: str | None = None
138
+
139
+ def has_permission(self, source, info, **kwargs) -> bool:
140
+ auth_ctx: JwtAuthContext = _extract_auth_context(self, info, **kwargs)
141
+ _check_preprocessing_errors(self, auth_ctx)
142
+
143
+ is_authenticated, self._error_code = IsService.is_context_service_authenticated(auth_ctx)
144
+ return is_authenticated
145
+
146
+ @staticmethod
147
+ def is_context_service_authenticated(auth_ctx: JwtAuthContext) -> tuple[bool, str | None]:
148
+ is_authenticated, error_code = IsServiceOrUser.is_context_authenticated(auth_ctx)
149
+ if not is_authenticated:
150
+ return (is_authenticated, error_code)
151
+
152
+ if auth_ctx.auth_subject_type != "service":
153
+ return (False, "not_authenticated_as_service")
154
+
155
+ return (True, None)
156
+
157
+ def graphql_client_error_args(self) -> tuple[Any, ...]:
158
+ return (self._error_code,)
159
+
160
+
161
+ @fastql_type_error
162
+ class AuthorizationServerError(GraphQLServerError):
163
+ """Error raised when authorization fails due to an error on the server side"""
164
+
165
+ def __init__(self, code) -> None:
166
+ super().__init__(code)
167
+
168
+
169
+ @fastql_type_error
170
+ class AuthorizedRoleRequiredError(GraphQLClientError):
171
+ """Error raised when an authorization role is required but it is missing"""
172
+
173
+ permitted_role: str
174
+
175
+ def __init__(self, permitted_role: str) -> None:
176
+ super().__init__("authorization_role_required")
177
+ self.permitted_role = permitted_role
178
+
179
+
180
+ class HasRole(GraphQLPermission, WithLogger):
181
+ graphql_client_error_class = AuthorizedRoleRequiredError
182
+ graphql_server_error_class = AuthorizationServerError
183
+
184
+ def __init__(self, role: str):
185
+ self._role = role
186
+
187
+ def has_permission(self, source, info, **kwargs) -> bool:
188
+ auth_ctx: JwtAuthContext = _extract_auth_context(self, info, **kwargs)
189
+ _check_preprocessing_errors(self, auth_ctx)
190
+
191
+ return bool(auth_ctx.iam_role and self._role == auth_ctx.iam_role)
192
+
193
+ def graphql_client_error_args(self) -> tuple[Any, ...]:
194
+ return (self._role,)
195
+
196
+
197
+ @fastql_type_error
198
+ class AuthorizedScopeRequiredError(GraphQLClientError):
199
+ """Error raised when an authorization scope is required but it is missing"""
200
+
201
+ permitted_scope: str
202
+
203
+ def __init__(self, permitted_scope: str) -> None:
204
+ super().__init__("authorization_scope_required")
205
+ self.permitted_scope = permitted_scope
206
+
207
+
208
+ class HasScope(GraphQLPermission, WithLogger):
209
+ graphql_client_error_class = AuthorizedScopeRequiredError
210
+ graphql_server_error_class = AuthorizationServerError
211
+
212
+ def __init__(self, scope: str):
213
+ self._scope = scope
214
+
215
+ def has_permission(self, source, info, **kwargs) -> bool:
216
+ auth_ctx: JwtAuthContext = _extract_auth_context(self, info, **kwargs)
217
+ _check_preprocessing_errors(self, auth_ctx)
218
+
219
+ return self._check_scope(auth_ctx)
220
+
221
+ def graphql_client_error_args(self) -> tuple[Any, ...]:
222
+ return (self._scope,)
223
+
224
+ def _check_scope(self, auth_ctx: JwtAuthContext) -> bool:
225
+ # Allow hierarchical scopes like "users:read" implied by "users:*"
226
+ if not auth_ctx.iam_scopes:
227
+ return False
228
+ if self._scope in auth_ctx.iam_scopes:
229
+ return True
230
+
231
+ prefix = self._scope.split(":")[0] + ":*"
232
+ return prefix in auth_ctx.iam_scopes
233
+
234
+
235
+ @fastql_type_error
236
+ class AuthorizedRoleOrScopeRequiredError(GraphQLClientError):
237
+ """Error raised when an authorization role is required but it is missing"""
238
+
239
+ permitted_roles: list[str]
240
+ permitted_scopes: list[str]
241
+
242
+ def __init__(self, permitted_roles: list[str], permitted_scopes: list[str]) -> None:
243
+ super().__init__("authorization_role_or_scope_required")
244
+ self.permitted_roles = permitted_roles
245
+ self.permitted_scopes = permitted_scopes
246
+
247
+
248
+ class HasRoleOrScope(GraphQLPermission, WithLogger):
249
+ graphql_client_error_class = AuthorizedRoleOrScopeRequiredError
250
+ graphql_server_error_class = AuthorizationServerError
251
+
252
+ def __init__(self, roles: Sequence[str | HasRole] = (), scopes: Sequence[str | HasScope] = ()):
253
+ self._roles = roles
254
+ self._role_permissions: list[HasRole] = [
255
+ role if isinstance(role, HasRole) else HasRole(role) for role in roles
256
+ ]
257
+ self._scopes = scopes
258
+ self._scope_permissions: list[HasScope] = [
259
+ scope if isinstance(scope, HasScope) else HasScope(scope) for scope in scopes
260
+ ]
261
+
262
+ def has_permission(self, source, info, **kwargs) -> bool:
263
+ auth_ctx: JwtAuthContext = _extract_auth_context(self, info, **kwargs)
264
+ _check_preprocessing_errors(self, auth_ctx)
265
+
266
+ has_role: bool = any(
267
+ perm.has_permission(source, info, auth_ctx=auth_ctx) for perm in self._role_permissions
268
+ )
269
+ has_scope: bool = False
270
+ if not has_role:
271
+ has_scope = any(
272
+ perm.has_permission(source, info, auth_ctx=auth_ctx)
273
+ for perm in self._scope_permissions
274
+ )
275
+ return bool(has_role or has_scope)
276
+
277
+ def graphql_client_error_args(self) -> tuple[Any, ...]:
278
+ return (
279
+ self._roles,
280
+ self._scopes,
281
+ )